├── .github └── workflows │ ├── pre-commit.yaml │ ├── pypi-publish.yaml │ └── pytest.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── Dockerfile ├── LICENSE ├── README.md ├── pyproject.toml ├── src └── mcp_server_qdrant │ ├── __init__.py │ ├── common │ ├── __init__.py │ ├── filters.py │ ├── func_tools.py │ └── wrap_filters.py │ ├── embeddings │ ├── __init__.py │ ├── base.py │ ├── factory.py │ ├── fastembed.py │ └── types.py │ ├── main.py │ ├── mcp_server.py │ ├── qdrant.py │ ├── server.py │ └── settings.py ├── tests ├── __init__.py ├── test_fastembed_integration.py ├── test_qdrant_integration.py └── test_settings.py └── uv.lock /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main, master, dev] 7 | 8 | jobs: 9 | main: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-python@v4 14 | with: 15 | python-version: 3.x 16 | - uses: pre-commit/action@v3.0.1 17 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: PyPI Publish 10 | 11 | on: 12 | workflow_dispatch: 13 | push: 14 | # Pattern matched against refs/tags 15 | tags: 16 | - 'v*' # Push events to every version tag 17 | 18 | env: 19 | UV_PUBLISH_TOKEN: '${{ secrets.PYPI_API_TOKEN }}' 20 | 21 | jobs: 22 | build: 23 | 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Set up Python 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: '3.10.x' 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install uv 37 | uv sync 38 | 39 | - name: Build package 40 | run: uv build 41 | 42 | - name: Publish package 43 | run: uv publish 44 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yaml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [main, master, dev] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | python-version: ['3.10', '3.11', '3.12', '3.13'] 15 | 16 | name: Python ${{ matrix.python-version }} 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v4 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install uv 30 | uv sync 31 | 32 | - name: Run tests 33 | run: | 34 | uv run pytest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | 164 | # Project-specific settings 165 | .aider* 166 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_prs: true 3 | autoupdate_commit_msg: '[pre-commit.ci] pre-commit suggestions' 4 | autoupdate_schedule: quarterly 5 | # submodules: true 6 | 7 | repos: 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: check-yaml 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - id: check-ast 15 | - id: check-added-large-files 16 | 17 | - repo: https://github.com/astral-sh/ruff-pre-commit 18 | rev: v0.9.10 19 | hooks: 20 | - id: ruff 21 | args: [ --fix ] 22 | - id: ruff-format 23 | 24 | - repo: https://github.com/PyCQA/isort 25 | rev: 5.12.0 26 | hooks: 27 | - id: isort 28 | name: "Sort Imports" 29 | args: [ "--profile", "black" ] 30 | 31 | - repo: https://github.com/pre-commit/mirrors-mypy 32 | rev: v1.9.0 33 | hooks: 34 | - id: mypy 35 | additional_dependencies: [tokenize-rt==3.2.0] 36 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install uv for package management 6 | RUN pip install --no-cache-dir uv 7 | 8 | # Install the mcp-server-qdrant package 9 | RUN uv pip install --system --no-cache-dir mcp-server-qdrant 10 | 11 | # Expose the default port for SSE transport 12 | EXPOSE 8000 13 | 14 | # Set environment variables with defaults that can be overridden at runtime 15 | ENV QDRANT_URL="" 16 | ENV QDRANT_API_KEY="" 17 | ENV COLLECTION_NAME="default-collection" 18 | ENV EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" 19 | 20 | # Run the server with SSE transport 21 | CMD uvx mcp-server-qdrant --transport sse 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcp-server-qdrant: A Qdrant MCP server 2 | 3 | [![smithery badge](https://smithery.ai/badge/mcp-server-qdrant)](https://smithery.ai/protocol/mcp-server-qdrant) 4 | 5 | > The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) is an open protocol that enables 6 | > seamless integration between LLM applications and external data sources and tools. Whether you're building an 7 | > AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to 8 | > connect LLMs with the context they need. 9 | 10 | This repository is an example of how to create a MCP server for [Qdrant](https://qdrant.tech/), a vector search engine. 11 | 12 | ## Overview 13 | 14 | An official Model Context Protocol server for keeping and retrieving memories in the Qdrant vector search engine. 15 | It acts as a semantic memory layer on top of the Qdrant database. 16 | 17 | ## Components 18 | 19 | ### Tools 20 | 21 | 1. `qdrant-store` 22 | - Store some information in the Qdrant database 23 | - Input: 24 | - `information` (string): Information to store 25 | - `metadata` (JSON): Optional metadata to store 26 | - `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name. 27 | If there is a default collection name, this field is not enabled. 28 | - Returns: Confirmation message 29 | 2. `qdrant-find` 30 | - Retrieve relevant information from the Qdrant database 31 | - Input: 32 | - `query` (string): Query to use for searching 33 | - `collection_name` (string): Name of the collection to store the information in. This field is required if there are no default collection name. 34 | If there is a default collection name, this field is not enabled. 35 | - Returns: Information stored in the Qdrant database as separate messages 36 | 37 | ## Environment Variables 38 | 39 | The configuration of the server is done using environment variables: 40 | 41 | | Name | Description | Default Value | 42 | |--------------------------|---------------------------------------------------------------------|-------------------------------------------------------------------| 43 | | `QDRANT_URL` | URL of the Qdrant server | None | 44 | | `QDRANT_API_KEY` | API key for the Qdrant server | None | 45 | | `COLLECTION_NAME` | Name of the default collection to use. | None | 46 | | `QDRANT_LOCAL_PATH` | Path to the local Qdrant database (alternative to `QDRANT_URL`) | None | 47 | | `EMBEDDING_PROVIDER` | Embedding provider to use (currently only "fastembed" is supported) | `fastembed` | 48 | | `EMBEDDING_MODEL` | Name of the embedding model to use | `sentence-transformers/all-MiniLM-L6-v2` | 49 | | `TOOL_STORE_DESCRIPTION` | Custom description for the store tool | See default in [`settings.py`](src/mcp_server_qdrant/settings.py) | 50 | | `TOOL_FIND_DESCRIPTION` | Custom description for the find tool | See default in [`settings.py`](src/mcp_server_qdrant/settings.py) | 51 | 52 | Note: You cannot provide both `QDRANT_URL` and `QDRANT_LOCAL_PATH` at the same time. 53 | 54 | > [!IMPORTANT] 55 | > Command-line arguments are not supported anymore! Please use environment variables for all configuration. 56 | 57 | ### FastMCP Environment Variables 58 | 59 | Since `mcp-server-qdrant` is based on FastMCP, it also supports all the FastMCP environment variables. The most 60 | important ones are listed below: 61 | 62 | | Environment Variable | Description | Default Value | 63 | |---------------------------------------|-----------------------------------------------------------|---------------| 64 | | `FASTMCP_DEBUG` | Enable debug mode | `false` | 65 | | `FASTMCP_LOG_LEVEL` | Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | `INFO` | 66 | | `FASTMCP_HOST` | Host address to bind the server to | `127.0.0.1` | 67 | | `FASTMCP_PORT` | Port to run the server on | `8000` | 68 | | `FASTMCP_WARN_ON_DUPLICATE_RESOURCES` | Show warnings for duplicate resources | `true` | 69 | | `FASTMCP_WARN_ON_DUPLICATE_TOOLS` | Show warnings for duplicate tools | `true` | 70 | | `FASTMCP_WARN_ON_DUPLICATE_PROMPTS` | Show warnings for duplicate prompts | `true` | 71 | | `FASTMCP_DEPENDENCIES` | List of dependencies to install in the server environment | `[]` | 72 | 73 | ## Installation 74 | 75 | ### Using uvx 76 | 77 | When using [`uvx`](https://docs.astral.sh/uv/guides/tools/#running-tools) no specific installation is needed to directly run *mcp-server-qdrant*. 78 | 79 | ```shell 80 | QDRANT_URL="http://localhost:6333" \ 81 | COLLECTION_NAME="my-collection" \ 82 | EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \ 83 | uvx mcp-server-qdrant 84 | ``` 85 | 86 | #### Transport Protocols 87 | 88 | The server supports different transport protocols that can be specified using the `--transport` flag: 89 | 90 | ```shell 91 | QDRANT_URL="http://localhost:6333" \ 92 | COLLECTION_NAME="my-collection" \ 93 | uvx mcp-server-qdrant --transport sse 94 | ``` 95 | 96 | Supported transport protocols: 97 | 98 | - `stdio` (default): Standard input/output transport, might only be used by local MCP clients 99 | - `sse`: Server-Sent Events transport, perfect for remote clients 100 | - `streamable-http`: Streamable HTTP transport, perfect for remote clients, more recent than SSE 101 | 102 | The default transport is `stdio` if not specified. 103 | 104 | When SSE transport is used, the server will listen on the specified port and wait for incoming connections. The default 105 | port is 8000, however it can be changed using the `FASTMCP_PORT` environment variable. 106 | 107 | ```shell 108 | QDRANT_URL="http://localhost:6333" \ 109 | COLLECTION_NAME="my-collection" \ 110 | FASTMCP_PORT=1234 \ 111 | uvx mcp-server-qdrant --transport sse 112 | ``` 113 | 114 | ### Using Docker 115 | 116 | A Dockerfile is available for building and running the MCP server: 117 | 118 | ```bash 119 | # Build the container 120 | docker build -t mcp-server-qdrant . 121 | 122 | # Run the container 123 | docker run -p 8000:8000 \ 124 | -e FASTMCP_HOST="0.0.0.0" \ 125 | -e QDRANT_URL="http://your-qdrant-server:6333" \ 126 | -e QDRANT_API_KEY="your-api-key" \ 127 | -e COLLECTION_NAME="your-collection" \ 128 | mcp-server-qdrant 129 | ``` 130 | 131 | > [!TIP] 132 | > Please note that we set `FASTMCP_HOST="0.0.0.0"` to make the server listen on all network interfaces. This is 133 | > necessary when running the server in a Docker container. 134 | 135 | ### Installing via Smithery 136 | 137 | To install Qdrant MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/protocol/mcp-server-qdrant): 138 | 139 | ```bash 140 | npx @smithery/cli install mcp-server-qdrant --client claude 141 | ``` 142 | 143 | ### Manual configuration of Claude Desktop 144 | 145 | To use this server with the Claude Desktop app, add the following configuration to the "mcpServers" section of your 146 | `claude_desktop_config.json`: 147 | 148 | ```json 149 | { 150 | "qdrant": { 151 | "command": "uvx", 152 | "args": ["mcp-server-qdrant"], 153 | "env": { 154 | "QDRANT_URL": "https://xyz-example.eu-central.aws.cloud.qdrant.io:6333", 155 | "QDRANT_API_KEY": "your_api_key", 156 | "COLLECTION_NAME": "your-collection-name", 157 | "EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2" 158 | } 159 | } 160 | } 161 | ``` 162 | 163 | For local Qdrant mode: 164 | 165 | ```json 166 | { 167 | "qdrant": { 168 | "command": "uvx", 169 | "args": ["mcp-server-qdrant"], 170 | "env": { 171 | "QDRANT_LOCAL_PATH": "/path/to/qdrant/database", 172 | "COLLECTION_NAME": "your-collection-name", 173 | "EMBEDDING_MODEL": "sentence-transformers/all-MiniLM-L6-v2" 174 | } 175 | } 176 | } 177 | ``` 178 | 179 | This MCP server will automatically create a collection with the specified name if it doesn't exist. 180 | 181 | By default, the server will use the `sentence-transformers/all-MiniLM-L6-v2` embedding model to encode memories. 182 | For the time being, only [FastEmbed](https://qdrant.github.io/fastembed/) models are supported. 183 | 184 | ## Support for other tools 185 | 186 | This MCP server can be used with any MCP-compatible client. For example, you can use it with 187 | [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 188 | Protocol. 189 | 190 | ### Using with Cursor/Windsurf 191 | 192 | You can configure this MCP server to work as a code search tool for Cursor or Windsurf by customizing the tool 193 | descriptions: 194 | 195 | ```bash 196 | QDRANT_URL="http://localhost:6333" \ 197 | COLLECTION_NAME="code-snippets" \ 198 | TOOL_STORE_DESCRIPTION="Store reusable code snippets for later retrieval. \ 199 | The 'information' parameter should contain a natural language description of what the code does, \ 200 | while the actual code should be included in the 'metadata' parameter as a 'code' property. \ 201 | The value of 'metadata' is a Python dictionary with strings as keys. \ 202 | Use this whenever you generate some code snippet." \ 203 | TOOL_FIND_DESCRIPTION="Search for relevant code snippets based on natural language descriptions. \ 204 | The 'query' parameter should describe what you're looking for, \ 205 | and the tool will return the most relevant code snippets. \ 206 | Use this when you need to find existing code snippets for reuse or reference." \ 207 | uvx mcp-server-qdrant --transport sse # Enable SSE transport 208 | ``` 209 | 210 | In Cursor/Windsurf, you can then configure the MCP server in your settings by pointing to this running server using 211 | SSE transport protocol. The description on how to add an MCP server to Cursor can be found in the [Cursor 212 | documentation](https://docs.cursor.com/context/model-context-protocol#adding-an-mcp-server-to-cursor). If you are 213 | running Cursor/Windsurf locally, you can use the following URL: 214 | 215 | ``` 216 | http://localhost:8000/sse 217 | ``` 218 | 219 | > [!TIP] 220 | > We suggest SSE transport as a preferred way to connect Cursor/Windsurf to the MCP server, as it can support remote 221 | > connections. That makes it easy to share the server with your team or use it in a cloud environment. 222 | 223 | This configuration transforms the Qdrant MCP server into a specialized code search tool that can: 224 | 225 | 1. Store code snippets, documentation, and implementation details 226 | 2. Retrieve relevant code examples based on semantic search 227 | 3. Help developers find specific implementations or usage patterns 228 | 229 | You can populate the database by storing natural language descriptions of code snippets (in the `information` parameter) 230 | along with the actual code (in the `metadata.code` property), and then search for them using natural language queries 231 | that describe what you're looking for. 232 | 233 | > [!NOTE] 234 | > The tool descriptions provided above are examples and may need to be customized for your specific use case. Consider 235 | > adjusting the descriptions to better match your team's workflow and the specific types of code snippets you want to 236 | > store and retrieve. 237 | 238 | **If you have successfully installed the `mcp-server-qdrant`, but still can't get it to work with Cursor, please 239 | consider creating the [Cursor rules](https://docs.cursor.com/context/rules-for-ai) so the MCP tools are always used when 240 | the agent produces a new code snippet.** You can restrict the rules to only work for certain file types, to avoid using 241 | the MCP server for the documentation or other types of content. 242 | 243 | ### Using with Claude Code 244 | 245 | You can enhance Claude Code's capabilities by connecting it to this MCP server, enabling semantic search over your 246 | existing codebase. 247 | 248 | #### Setting up mcp-server-qdrant 249 | 250 | 1. Add the MCP server to Claude Code: 251 | 252 | ```shell 253 | # Add mcp-server-qdrant configured for code search 254 | claude mcp add code-search \ 255 | -e QDRANT_URL="http://localhost:6333" \ 256 | -e COLLECTION_NAME="code-repository" \ 257 | -e EMBEDDING_MODEL="sentence-transformers/all-MiniLM-L6-v2" \ 258 | -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." \ 259 | -e TOOL_FIND_DESCRIPTION="Search for relevant code snippets using natural language. The 'query' parameter should describe the functionality you're looking for." \ 260 | -- uvx mcp-server-qdrant 261 | ``` 262 | 263 | 2. Verify the server was added: 264 | 265 | ```shell 266 | claude mcp list 267 | ``` 268 | 269 | #### Using Semantic Code Search in Claude Code 270 | 271 | Tool descriptions, specified in `TOOL_STORE_DESCRIPTION` and `TOOL_FIND_DESCRIPTION`, guide Claude Code on how to use 272 | the MCP server. The ones provided above are examples and may need to be customized for your specific use case. However, 273 | Claude Code should be already able to: 274 | 275 | 1. Use the `qdrant-store` tool to store code snippets with descriptions. 276 | 2. Use the `qdrant-find` tool to search for relevant code snippets using natural language. 277 | 278 | ### Run MCP server in Development Mode 279 | 280 | The MCP server can be run in development mode using the `mcp dev` command. This will start the server and open the MCP 281 | inspector in your browser. 282 | 283 | ```shell 284 | COLLECTION_NAME=mcp-dev fastmcp dev src/mcp_server_qdrant/server.py 285 | ``` 286 | 287 | ### Using with VS Code 288 | 289 | For one-click installation, click one of the install buttons below: 290 | 291 | [![Install with UVX in VS Code](https://img.shields.io/badge/VS_Code-UVX-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D) [![Install with UVX in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-UVX-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D&quality=insiders) 292 | 293 | [![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Docker-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-p%22%2C%228000%3A8000%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22QDRANT_URL%22%2C%22-e%22%2C%22QDRANT_API_KEY%22%2C%22-e%22%2C%22COLLECTION_NAME%22%2C%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Docker-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=qdrant&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-p%22%2C%228000%3A8000%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22QDRANT_URL%22%2C%22-e%22%2C%22QDRANT_API_KEY%22%2C%22-e%22%2C%22COLLECTION_NAME%22%2C%22mcp-server-qdrant%22%5D%2C%22env%22%3A%7B%22QDRANT_URL%22%3A%22%24%7Binput%3AqdrantUrl%7D%22%2C%22QDRANT_API_KEY%22%3A%22%24%7Binput%3AqdrantApiKey%7D%22%2C%22COLLECTION_NAME%22%3A%22%24%7Binput%3AcollectionName%7D%22%7D%7D&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantUrl%22%2C%22description%22%3A%22Qdrant+URL%22%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22qdrantApiKey%22%2C%22description%22%3A%22Qdrant+API+Key%22%2C%22password%22%3Atrue%7D%2C%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22collectionName%22%2C%22description%22%3A%22Collection+Name%22%7D%5D&quality=insiders) 294 | 295 | #### Manual Installation 296 | 297 | 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)`. 298 | 299 | ```json 300 | { 301 | "mcp": { 302 | "inputs": [ 303 | { 304 | "type": "promptString", 305 | "id": "qdrantUrl", 306 | "description": "Qdrant URL" 307 | }, 308 | { 309 | "type": "promptString", 310 | "id": "qdrantApiKey", 311 | "description": "Qdrant API Key", 312 | "password": true 313 | }, 314 | { 315 | "type": "promptString", 316 | "id": "collectionName", 317 | "description": "Collection Name" 318 | } 319 | ], 320 | "servers": { 321 | "qdrant": { 322 | "command": "uvx", 323 | "args": ["mcp-server-qdrant"], 324 | "env": { 325 | "QDRANT_URL": "${input:qdrantUrl}", 326 | "QDRANT_API_KEY": "${input:qdrantApiKey}", 327 | "COLLECTION_NAME": "${input:collectionName}" 328 | } 329 | } 330 | } 331 | } 332 | } 333 | ``` 334 | 335 | Or if you prefer using Docker, add this configuration instead: 336 | 337 | ```json 338 | { 339 | "mcp": { 340 | "inputs": [ 341 | { 342 | "type": "promptString", 343 | "id": "qdrantUrl", 344 | "description": "Qdrant URL" 345 | }, 346 | { 347 | "type": "promptString", 348 | "id": "qdrantApiKey", 349 | "description": "Qdrant API Key", 350 | "password": true 351 | }, 352 | { 353 | "type": "promptString", 354 | "id": "collectionName", 355 | "description": "Collection Name" 356 | } 357 | ], 358 | "servers": { 359 | "qdrant": { 360 | "command": "docker", 361 | "args": [ 362 | "run", 363 | "-p", "8000:8000", 364 | "-i", 365 | "--rm", 366 | "-e", "QDRANT_URL", 367 | "-e", "QDRANT_API_KEY", 368 | "-e", "COLLECTION_NAME", 369 | "mcp-server-qdrant" 370 | ], 371 | "env": { 372 | "QDRANT_URL": "${input:qdrantUrl}", 373 | "QDRANT_API_KEY": "${input:qdrantApiKey}", 374 | "COLLECTION_NAME": "${input:collectionName}" 375 | } 376 | } 377 | } 378 | } 379 | } 380 | ``` 381 | 382 | Alternatively, you can create a `.vscode/mcp.json` file in your workspace with the following content: 383 | 384 | ```json 385 | { 386 | "inputs": [ 387 | { 388 | "type": "promptString", 389 | "id": "qdrantUrl", 390 | "description": "Qdrant URL" 391 | }, 392 | { 393 | "type": "promptString", 394 | "id": "qdrantApiKey", 395 | "description": "Qdrant API Key", 396 | "password": true 397 | }, 398 | { 399 | "type": "promptString", 400 | "id": "collectionName", 401 | "description": "Collection Name" 402 | } 403 | ], 404 | "servers": { 405 | "qdrant": { 406 | "command": "uvx", 407 | "args": ["mcp-server-qdrant"], 408 | "env": { 409 | "QDRANT_URL": "${input:qdrantUrl}", 410 | "QDRANT_API_KEY": "${input:qdrantApiKey}", 411 | "COLLECTION_NAME": "${input:collectionName}" 412 | } 413 | } 414 | } 415 | } 416 | ``` 417 | 418 | For workspace configuration with Docker, use this in `.vscode/mcp.json`: 419 | 420 | ```json 421 | { 422 | "inputs": [ 423 | { 424 | "type": "promptString", 425 | "id": "qdrantUrl", 426 | "description": "Qdrant URL" 427 | }, 428 | { 429 | "type": "promptString", 430 | "id": "qdrantApiKey", 431 | "description": "Qdrant API Key", 432 | "password": true 433 | }, 434 | { 435 | "type": "promptString", 436 | "id": "collectionName", 437 | "description": "Collection Name" 438 | } 439 | ], 440 | "servers": { 441 | "qdrant": { 442 | "command": "docker", 443 | "args": [ 444 | "run", 445 | "-p", "8000:8000", 446 | "-i", 447 | "--rm", 448 | "-e", "QDRANT_URL", 449 | "-e", "QDRANT_API_KEY", 450 | "-e", "COLLECTION_NAME", 451 | "mcp-server-qdrant" 452 | ], 453 | "env": { 454 | "QDRANT_URL": "${input:qdrantUrl}", 455 | "QDRANT_API_KEY": "${input:qdrantApiKey}", 456 | "COLLECTION_NAME": "${input:collectionName}" 457 | } 458 | } 459 | } 460 | } 461 | ``` 462 | 463 | ## Contributing 464 | 465 | If you have suggestions for how mcp-server-qdrant could be improved, or want to report a bug, open an issue! 466 | We'd love all and any contributions. 467 | 468 | ### Testing `mcp-server-qdrant` locally 469 | 470 | The [MCP inspector](https://github.com/modelcontextprotocol/inspector) is a developer tool for testing and debugging MCP 471 | servers. It runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in 472 | your browser to use the inspector. 473 | 474 | ```shell 475 | QDRANT_URL=":memory:" COLLECTION_NAME="test" \ 476 | fastmcp dev src/mcp_server_qdrant/server.py 477 | ``` 478 | 479 | Once started, open your browser to http://localhost:5173 to access the inspector interface. 480 | 481 | ## License 482 | 483 | This MCP server is licensed under the Apache License 2.0. This means you are free to use, modify, and distribute the 484 | software, subject to the terms and conditions of the Apache License 2.0. For more details, please see the LICENSE file 485 | in the project repository. 486 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mcp-server-qdrant" 3 | version = "0.8.0" 4 | description = "MCP server for retrieving context from a Qdrant vector database" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | license = "Apache-2.0" 8 | dependencies = [ 9 | "fastembed>=0.6.0", 10 | "qdrant-client>=1.12.0", 11 | "pydantic>=2.10.6", 12 | "fastmcp>=2.7.0", 13 | ] 14 | 15 | [build-system] 16 | requires = ["hatchling"] 17 | build-backend = "hatchling.build" 18 | 19 | [tool.uv] 20 | dev-dependencies = [ 21 | "ipdb>=0.13.13", 22 | "isort>=6.0.1", 23 | "mypy>=1.9.0", 24 | "pre-commit>=4.1.0", 25 | "pyright>=1.1.389", 26 | "pytest>=8.3.3", 27 | "pytest-asyncio>=0.23.0", 28 | "ruff>=0.8.0", 29 | ] 30 | 31 | [project.scripts] 32 | mcp-server-qdrant = "mcp_server_qdrant.main:main" 33 | 34 | [tool.pytest.ini_options] 35 | testpaths = ["tests"] 36 | python_files = "test_*.py" 37 | python_functions = "test_*" 38 | asyncio_mode = "auto" 39 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdrant/mcp-server-qdrant/5a7237389ea7fa393e2a22af57116e88cb32b38e/src/mcp_server_qdrant/__init__.py -------------------------------------------------------------------------------- /src/mcp_server_qdrant/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdrant/mcp-server-qdrant/5a7237389ea7fa393e2a22af57116e88cb32b38e/src/mcp_server_qdrant/common/__init__.py -------------------------------------------------------------------------------- /src/mcp_server_qdrant/common/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from qdrant_client import models 4 | 5 | from mcp_server_qdrant.qdrant import ArbitraryFilter 6 | from mcp_server_qdrant.settings import METADATA_PATH, FilterableField 7 | 8 | 9 | def make_filter( 10 | filterable_fields: dict[str, FilterableField], values: dict[str, Any] 11 | ) -> ArbitraryFilter: 12 | must_conditions = [] 13 | must_not_conditions = [] 14 | 15 | for raw_field_name, field_value in values.items(): 16 | if raw_field_name not in filterable_fields: 17 | raise ValueError(f"Field {raw_field_name} is not a filterable field") 18 | 19 | field = filterable_fields[raw_field_name] 20 | 21 | if field_value is None: 22 | if field.required: 23 | raise ValueError(f"Field {raw_field_name} is required") 24 | else: 25 | continue 26 | 27 | field_name = f"{METADATA_PATH}.{raw_field_name}" 28 | 29 | if field.field_type == "keyword": 30 | if field.condition == "==": 31 | must_conditions.append( 32 | models.FieldCondition( 33 | key=field_name, match=models.MatchValue(value=field_value) 34 | ) 35 | ) 36 | elif field.condition == "!=": 37 | must_not_conditions.append( 38 | models.FieldCondition( 39 | key=field_name, match=models.MatchValue(value=field_value) 40 | ) 41 | ) 42 | elif field.condition == "any": 43 | must_conditions.append( 44 | models.FieldCondition( 45 | key=field_name, match=models.MatchAny(any=field_value) 46 | ) 47 | ) 48 | elif field.condition == "except": 49 | must_conditions.append( 50 | models.FieldCondition( 51 | key=field_name, 52 | match=models.MatchExcept(**{"except": field_value}), 53 | ) 54 | ) 55 | elif field.condition is not None: 56 | raise ValueError( 57 | f"Invalid condition {field.condition} for keyword field {field_name}" 58 | ) 59 | 60 | elif field.field_type == "integer": 61 | if field.condition == "==": 62 | must_conditions.append( 63 | models.FieldCondition( 64 | key=field_name, match=models.MatchValue(value=field_value) 65 | ) 66 | ) 67 | elif field.condition == "!=": 68 | must_not_conditions.append( 69 | models.FieldCondition( 70 | key=field_name, match=models.MatchValue(value=field_value) 71 | ) 72 | ) 73 | elif field.condition == ">": 74 | must_conditions.append( 75 | models.FieldCondition( 76 | key=field_name, range=models.Range(gt=field_value) 77 | ) 78 | ) 79 | elif field.condition == ">=": 80 | must_conditions.append( 81 | models.FieldCondition( 82 | key=field_name, range=models.Range(gte=field_value) 83 | ) 84 | ) 85 | elif field.condition == "<": 86 | must_conditions.append( 87 | models.FieldCondition( 88 | key=field_name, range=models.Range(lt=field_value) 89 | ) 90 | ) 91 | elif field.condition == "<=": 92 | must_conditions.append( 93 | models.FieldCondition( 94 | key=field_name, range=models.Range(lte=field_value) 95 | ) 96 | ) 97 | elif field.condition == "any": 98 | must_conditions.append( 99 | models.FieldCondition( 100 | key=field_name, match=models.MatchAny(any=field_value) 101 | ) 102 | ) 103 | elif field.condition == "except": 104 | must_conditions.append( 105 | models.FieldCondition( 106 | key=field_name, 107 | match=models.MatchExcept(**{"except": field_value}), 108 | ) 109 | ) 110 | elif field.condition is not None: 111 | raise ValueError( 112 | f"Invalid condition {field.condition} for integer field {field_name}" 113 | ) 114 | 115 | elif field.field_type == "float": 116 | # For float values, we only support range comparisons 117 | if field.condition == ">": 118 | must_conditions.append( 119 | models.FieldCondition( 120 | key=field_name, range=models.Range(gt=field_value) 121 | ) 122 | ) 123 | elif field.condition == ">=": 124 | must_conditions.append( 125 | models.FieldCondition( 126 | key=field_name, range=models.Range(gte=field_value) 127 | ) 128 | ) 129 | elif field.condition == "<": 130 | must_conditions.append( 131 | models.FieldCondition( 132 | key=field_name, range=models.Range(lt=field_value) 133 | ) 134 | ) 135 | elif field.condition == "<=": 136 | must_conditions.append( 137 | models.FieldCondition( 138 | key=field_name, range=models.Range(lte=field_value) 139 | ) 140 | ) 141 | elif field.condition is not None: 142 | raise ValueError( 143 | f"Invalid condition {field.condition} for float field {field_name}. " 144 | "Only range comparisons (>, >=, <, <=) are supported for float values." 145 | ) 146 | 147 | elif field.field_type == "boolean": 148 | if field.condition == "==": 149 | must_conditions.append( 150 | models.FieldCondition( 151 | key=field_name, match=models.MatchValue(value=field_value) 152 | ) 153 | ) 154 | elif field.condition == "!=": 155 | must_not_conditions.append( 156 | models.FieldCondition( 157 | key=field_name, match=models.MatchValue(value=field_value) 158 | ) 159 | ) 160 | elif field.condition is not None: 161 | raise ValueError( 162 | f"Invalid condition {field.condition} for boolean field {field_name}" 163 | ) 164 | 165 | else: 166 | raise ValueError( 167 | f"Unsupported field type {field.field_type} for field {field_name}" 168 | ) 169 | 170 | return models.Filter( 171 | must=must_conditions, must_not=must_not_conditions 172 | ).model_dump() 173 | 174 | 175 | def make_indexes( 176 | filterable_fields: dict[str, FilterableField], 177 | ) -> dict[str, models.PayloadSchemaType]: 178 | indexes = {} 179 | 180 | for field_name, field in filterable_fields.items(): 181 | if field.field_type == "keyword": 182 | indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.KEYWORD 183 | elif field.field_type == "integer": 184 | indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.INTEGER 185 | elif field.field_type == "float": 186 | indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.FLOAT 187 | elif field.field_type == "boolean": 188 | indexes[f"{METADATA_PATH}.{field_name}"] = models.PayloadSchemaType.BOOL 189 | else: 190 | raise ValueError( 191 | f"Unsupported field type {field.field_type} for field {field_name}" 192 | ) 193 | 194 | return indexes 195 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/common/func_tools.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | from typing import Callable 4 | 5 | 6 | def make_partial_function(original_func: Callable, fixed_values: dict) -> Callable: 7 | sig = inspect.signature(original_func) 8 | 9 | @wraps(original_func) 10 | def wrapper(*args, **kwargs): 11 | # Start with fixed values 12 | bound_args = dict(fixed_values) 13 | 14 | # Bind positional/keyword args from caller 15 | for name, value in zip(remaining_params, args): 16 | bound_args[name] = value 17 | bound_args.update(kwargs) 18 | 19 | return original_func(**bound_args) 20 | 21 | # Only keep parameters NOT in fixed_values 22 | remaining_params = [name for name in sig.parameters if name not in fixed_values] 23 | new_params = [sig.parameters[name] for name in remaining_params] 24 | 25 | # Set the new __signature__ for introspection 26 | wrapper.__signature__ = sig.replace(parameters=new_params) # type:ignore 27 | 28 | return wrapper 29 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/common/wrap_filters.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | from typing import Annotated, Callable, Optional 4 | 5 | from pydantic import Field 6 | 7 | from mcp_server_qdrant.common.filters import make_filter 8 | from mcp_server_qdrant.settings import FilterableField 9 | 10 | 11 | def wrap_filters( 12 | original_func: Callable, filterable_fields: dict[str, FilterableField] 13 | ) -> Callable: 14 | """ 15 | Wraps the original_func function: replaces `filter` parameter with multiple parameters defined by `filterable_fields`. 16 | """ 17 | 18 | sig = inspect.signature(original_func) 19 | 20 | @wraps(original_func) 21 | def wrapper(*args, **kwargs): 22 | # Start with fixed values 23 | filter_values = {} 24 | 25 | for field_name in filterable_fields: 26 | if field_name in kwargs: 27 | filter_values[field_name] = kwargs.pop(field_name) 28 | 29 | query_filter = make_filter(filterable_fields, filter_values) 30 | 31 | return original_func(**kwargs, query_filter=query_filter) 32 | 33 | # Replace `query_filter` signature with parameters from `filterable_fields` 34 | 35 | param_names = [] 36 | 37 | for param_name in sig.parameters: 38 | if param_name == "query_filter": 39 | continue 40 | param_names.append(param_name) 41 | 42 | new_params = [sig.parameters[param_name] for param_name in param_names] 43 | required_new_params = [] 44 | optional_new_params = [] 45 | 46 | # Create a new signature parameters from `filterable_fields` 47 | for field in filterable_fields.values(): 48 | field_name = field.name 49 | field_type: type 50 | if field.field_type == "keyword": 51 | field_type = str 52 | elif field.field_type == "integer": 53 | field_type = int 54 | elif field.field_type == "float": 55 | field_type = float 56 | elif field.field_type == "boolean": 57 | field_type = bool 58 | else: 59 | raise ValueError(f"Unsupported field type: {field.field_type}") 60 | 61 | if field.condition in {"any", "except"}: 62 | if field_type not in {str, int}: 63 | raise ValueError( 64 | f'Only "keyword" and "integer" types are supported for "{field.condition}" condition' 65 | ) 66 | field_type = list[field_type] # type: ignore 67 | 68 | if field.required: 69 | annotation = Annotated[field_type, Field(description=field.description)] # type: ignore 70 | parameter = inspect.Parameter( 71 | name=field_name, 72 | kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, 73 | annotation=annotation, 74 | ) 75 | required_new_params.append(parameter) 76 | else: 77 | annotation = Annotated[ # type: ignore 78 | Optional[field_type], Field(description=field.description) 79 | ] 80 | parameter = inspect.Parameter( 81 | name=field_name, 82 | kind=inspect.Parameter.POSITIONAL_OR_KEYWORD, 83 | default=None, 84 | annotation=annotation, 85 | ) 86 | optional_new_params.append(parameter) 87 | 88 | new_params.extend(required_new_params) 89 | new_params.extend(optional_new_params) 90 | 91 | # Set the new __signature__ for introspection 92 | new_signature = sig.replace(parameters=new_params) 93 | wrapper.__signature__ = new_signature # type: ignore 94 | 95 | # Set the new __annotations__ for introspection 96 | new_annotations = {} 97 | for param in new_signature.parameters.values(): 98 | if param.annotation != inspect.Parameter.empty: 99 | new_annotations[param.name] = param.annotation 100 | 101 | # Add return type annotation if it exists 102 | if new_signature.return_annotation != inspect.Parameter.empty: 103 | new_annotations["return"] = new_signature.return_annotation 104 | 105 | wrapper.__annotations__ = new_annotations 106 | 107 | return wrapper 108 | 109 | 110 | if __name__ == "__main__": 111 | from pydantic._internal._typing_extra import get_function_type_hints 112 | from qdrant_client import models 113 | 114 | def find( 115 | query: Annotated[str, Field(description="What to search for")], 116 | collection_name: Annotated[ 117 | str, Field(description="The collection to search in") 118 | ], 119 | query_filter: Optional[models.Filter] = None, 120 | ) -> list[str]: 121 | print("query", query) 122 | print("collection_name", collection_name) 123 | print("query_filter", query_filter) 124 | return ["mypy rules"] 125 | 126 | wrapped_find = wrap_filters( 127 | find, 128 | { 129 | "color": FilterableField( 130 | name="color", 131 | description="The color of the object", 132 | field_type="keyword", 133 | condition="==", 134 | ), 135 | "size": FilterableField( 136 | name="size", 137 | description="The size of the object", 138 | field_type="keyword", 139 | condition="==", 140 | required=True, 141 | ), 142 | }, 143 | ) 144 | 145 | wrapped_find(query="dress", collection_name="test", color="red") 146 | 147 | print("get_function_type_hints(find)", get_function_type_hints(find)) 148 | print( 149 | "get_function_type_hints(wrapped_find)", get_function_type_hints(wrapped_find) 150 | ) 151 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/embeddings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qdrant/mcp-server-qdrant/5a7237389ea7fa393e2a22af57116e88cb32b38e/src/mcp_server_qdrant/embeddings/__init__.py -------------------------------------------------------------------------------- /src/mcp_server_qdrant/embeddings/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class EmbeddingProvider(ABC): 5 | """Abstract base class for embedding providers.""" 6 | 7 | @abstractmethod 8 | async def embed_documents(self, documents: list[str]) -> list[list[float]]: 9 | """Embed a list of documents into vectors.""" 10 | pass 11 | 12 | @abstractmethod 13 | async def embed_query(self, query: str) -> list[float]: 14 | """Embed a query into a vector.""" 15 | pass 16 | 17 | @abstractmethod 18 | def get_vector_name(self) -> str: 19 | """Get the name of the vector for the Qdrant collection.""" 20 | pass 21 | 22 | @abstractmethod 23 | def get_vector_size(self) -> int: 24 | """Get the size of the vector for the Qdrant collection.""" 25 | pass 26 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/embeddings/factory.py: -------------------------------------------------------------------------------- 1 | from mcp_server_qdrant.embeddings.base import EmbeddingProvider 2 | from mcp_server_qdrant.embeddings.types import EmbeddingProviderType 3 | from mcp_server_qdrant.settings import EmbeddingProviderSettings 4 | 5 | 6 | def create_embedding_provider(settings: EmbeddingProviderSettings) -> EmbeddingProvider: 7 | """ 8 | Create an embedding provider based on the specified type. 9 | :param settings: The settings for the embedding provider. 10 | :return: An instance of the specified embedding provider. 11 | """ 12 | if settings.provider_type == EmbeddingProviderType.FASTEMBED: 13 | from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider 14 | 15 | return FastEmbedProvider(settings.model_name) 16 | else: 17 | raise ValueError(f"Unsupported embedding provider: {settings.provider_type}") 18 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/embeddings/fastembed.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from fastembed import TextEmbedding 4 | from fastembed.common.model_description import DenseModelDescription 5 | 6 | from mcp_server_qdrant.embeddings.base import EmbeddingProvider 7 | 8 | 9 | class FastEmbedProvider(EmbeddingProvider): 10 | """ 11 | FastEmbed implementation of the embedding provider. 12 | :param model_name: The name of the FastEmbed model to use. 13 | """ 14 | 15 | def __init__(self, model_name: str): 16 | self.model_name = model_name 17 | self.embedding_model = TextEmbedding(model_name) 18 | 19 | async def embed_documents(self, documents: list[str]) -> list[list[float]]: 20 | """Embed a list of documents into vectors.""" 21 | # Run in a thread pool since FastEmbed is synchronous 22 | loop = asyncio.get_event_loop() 23 | embeddings = await loop.run_in_executor( 24 | None, lambda: list(self.embedding_model.passage_embed(documents)) 25 | ) 26 | return [embedding.tolist() for embedding in embeddings] 27 | 28 | async def embed_query(self, query: str) -> list[float]: 29 | """Embed a query into a vector.""" 30 | # Run in a thread pool since FastEmbed is synchronous 31 | loop = asyncio.get_event_loop() 32 | embeddings = await loop.run_in_executor( 33 | None, lambda: list(self.embedding_model.query_embed([query])) 34 | ) 35 | return embeddings[0].tolist() 36 | 37 | def get_vector_name(self) -> str: 38 | """ 39 | Return the name of the vector for the Qdrant collection. 40 | Important: This is compatible with the FastEmbed logic used before 0.6.0. 41 | """ 42 | model_name = self.embedding_model.model_name.split("/")[-1].lower() 43 | return f"fast-{model_name}" 44 | 45 | def get_vector_size(self) -> int: 46 | """Get the size of the vector for the Qdrant collection.""" 47 | model_description: DenseModelDescription = ( 48 | self.embedding_model._get_model_description(self.model_name) 49 | ) 50 | return model_description.dim 51 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/embeddings/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EmbeddingProviderType(Enum): 5 | FASTEMBED = "fastembed" 6 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | def main(): 5 | """ 6 | Main entry point for the mcp-server-qdrant script defined 7 | in pyproject.toml. It runs the MCP server with a specific transport 8 | protocol. 9 | """ 10 | 11 | # Parse the command-line arguments to determine the transport protocol. 12 | parser = argparse.ArgumentParser(description="mcp-server-qdrant") 13 | parser.add_argument( 14 | "--transport", 15 | choices=["stdio", "sse", "streamable-http"], 16 | default="stdio", 17 | ) 18 | args = parser.parse_args() 19 | 20 | # Import is done here to make sure environment variables are loaded 21 | # only after we make the changes. 22 | from mcp_server_qdrant.server import mcp 23 | 24 | mcp.run(transport=args.transport) 25 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/mcp_server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Annotated, Any 4 | 5 | from fastmcp import Context, FastMCP 6 | from pydantic import Field 7 | from qdrant_client import models 8 | 9 | from mcp_server_qdrant.common.filters import make_indexes 10 | from mcp_server_qdrant.common.func_tools import make_partial_function 11 | from mcp_server_qdrant.common.wrap_filters import wrap_filters 12 | from mcp_server_qdrant.embeddings.factory import create_embedding_provider 13 | from mcp_server_qdrant.qdrant import ArbitraryFilter, Entry, Metadata, QdrantConnector 14 | from mcp_server_qdrant.settings import ( 15 | EmbeddingProviderSettings, 16 | QdrantSettings, 17 | ToolSettings, 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | # FastMCP is an alternative interface for declaring the capabilities 24 | # of the server. Its API is based on FastAPI. 25 | class QdrantMCPServer(FastMCP): 26 | """ 27 | A MCP server for Qdrant. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | tool_settings: ToolSettings, 33 | qdrant_settings: QdrantSettings, 34 | embedding_provider_settings: EmbeddingProviderSettings, 35 | name: str = "mcp-server-qdrant", 36 | instructions: str | None = None, 37 | **settings: Any, 38 | ): 39 | self.tool_settings = tool_settings 40 | self.qdrant_settings = qdrant_settings 41 | self.embedding_provider_settings = embedding_provider_settings 42 | 43 | self.embedding_provider = create_embedding_provider(embedding_provider_settings) 44 | self.qdrant_connector = QdrantConnector( 45 | qdrant_settings.location, 46 | qdrant_settings.api_key, 47 | qdrant_settings.collection_name, 48 | self.embedding_provider, 49 | qdrant_settings.local_path, 50 | make_indexes(qdrant_settings.filterable_fields_dict()), 51 | ) 52 | 53 | super().__init__(name=name, instructions=instructions, **settings) 54 | 55 | self.setup_tools() 56 | 57 | def format_entry(self, entry: Entry) -> str: 58 | """ 59 | Feel free to override this method in your subclass to customize the format of the entry. 60 | """ 61 | entry_metadata = json.dumps(entry.metadata) if entry.metadata else "" 62 | return f"{entry.content}{entry_metadata}" 63 | 64 | def setup_tools(self): 65 | """ 66 | Register the tools in the server. 67 | """ 68 | 69 | async def store( 70 | ctx: Context, 71 | information: Annotated[str, Field(description="Text to store")], 72 | collection_name: Annotated[ 73 | str, Field(description="The collection to store the information in") 74 | ], 75 | # The `metadata` parameter is defined as non-optional, but it can be None. 76 | # If we set it to be optional, some of the MCP clients, like Cursor, cannot 77 | # handle the optional parameter correctly. 78 | metadata: Annotated[ 79 | Metadata | None, 80 | Field( 81 | description="Extra metadata stored along with memorised information. Any json is accepted." 82 | ), 83 | ] = None, 84 | ) -> str: 85 | """ 86 | Store some information in Qdrant. 87 | :param ctx: The context for the request. 88 | :param information: The information to store. 89 | :param metadata: JSON metadata to store with the information, optional. 90 | :param collection_name: The name of the collection to store the information in, optional. If not provided, 91 | the default collection is used. 92 | :return: A message indicating that the information was stored. 93 | """ 94 | await ctx.debug(f"Storing information {information} in Qdrant") 95 | 96 | entry = Entry(content=information, metadata=metadata) 97 | 98 | await self.qdrant_connector.store(entry, collection_name=collection_name) 99 | if collection_name: 100 | return f"Remembered: {information} in collection {collection_name}" 101 | return f"Remembered: {information}" 102 | 103 | async def find( 104 | ctx: Context, 105 | query: Annotated[str, Field(description="What to search for")], 106 | collection_name: Annotated[ 107 | str, Field(description="The collection to search in") 108 | ], 109 | query_filter: ArbitraryFilter | None = None, 110 | ) -> list[str]: 111 | """ 112 | Find memories in Qdrant. 113 | :param ctx: The context for the request. 114 | :param query: The query to use for the search. 115 | :param collection_name: The name of the collection to search in, optional. If not provided, 116 | the default collection is used. 117 | :param query_filter: The filter to apply to the query. 118 | :return: A list of entries found. 119 | """ 120 | 121 | # Log query_filter 122 | await ctx.debug(f"Query filter: {query_filter}") 123 | 124 | query_filter = models.Filter(**query_filter) if query_filter else None 125 | 126 | await ctx.debug(f"Finding results for query {query}") 127 | 128 | entries = await self.qdrant_connector.search( 129 | query, 130 | collection_name=collection_name, 131 | limit=self.qdrant_settings.search_limit, 132 | query_filter=query_filter, 133 | ) 134 | if not entries: 135 | return [f"No information found for the query '{query}'"] 136 | content = [ 137 | f"Results for the query '{query}'", 138 | ] 139 | for entry in entries: 140 | content.append(self.format_entry(entry)) 141 | return content 142 | 143 | find_foo = find 144 | store_foo = store 145 | 146 | filterable_conditions = ( 147 | self.qdrant_settings.filterable_fields_dict_with_conditions() 148 | ) 149 | 150 | if len(filterable_conditions) > 0: 151 | find_foo = wrap_filters(find_foo, filterable_conditions) 152 | elif not self.qdrant_settings.allow_arbitrary_filter: 153 | find_foo = make_partial_function(find_foo, {"query_filter": None}) 154 | 155 | if self.qdrant_settings.collection_name: 156 | find_foo = make_partial_function( 157 | find_foo, {"collection_name": self.qdrant_settings.collection_name} 158 | ) 159 | store_foo = make_partial_function( 160 | store_foo, {"collection_name": self.qdrant_settings.collection_name} 161 | ) 162 | 163 | self.tool( 164 | find_foo, 165 | name="qdrant-find", 166 | description=self.tool_settings.tool_find_description, 167 | ) 168 | 169 | if not self.qdrant_settings.read_only: 170 | # Those methods can modify the database 171 | self.tool( 172 | store_foo, 173 | name="qdrant-store", 174 | description=self.tool_settings.tool_store_description, 175 | ) 176 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/qdrant.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from typing import Any 4 | 5 | from pydantic import BaseModel 6 | from qdrant_client import AsyncQdrantClient, models 7 | 8 | from mcp_server_qdrant.embeddings.base import EmbeddingProvider 9 | from mcp_server_qdrant.settings import METADATA_PATH 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | Metadata = dict[str, Any] 14 | ArbitraryFilter = dict[str, Any] 15 | 16 | 17 | class Entry(BaseModel): 18 | """ 19 | A single entry in the Qdrant collection. 20 | """ 21 | 22 | content: str 23 | metadata: Metadata | None = None 24 | 25 | 26 | class QdrantConnector: 27 | """ 28 | Encapsulates the connection to a Qdrant server and all the methods to interact with it. 29 | :param qdrant_url: The URL of the Qdrant server. 30 | :param qdrant_api_key: The API key to use for the Qdrant server. 31 | :param collection_name: The name of the default collection to use. If not provided, each tool will require 32 | the collection name to be provided. 33 | :param embedding_provider: The embedding provider to use. 34 | :param qdrant_local_path: The path to the storage directory for the Qdrant client, if local mode is used. 35 | """ 36 | 37 | def __init__( 38 | self, 39 | qdrant_url: str | None, 40 | qdrant_api_key: str | None, 41 | collection_name: str | None, 42 | embedding_provider: EmbeddingProvider, 43 | qdrant_local_path: str | None = None, 44 | field_indexes: dict[str, models.PayloadSchemaType] | None = None, 45 | ): 46 | self._qdrant_url = qdrant_url.rstrip("/") if qdrant_url else None 47 | self._qdrant_api_key = qdrant_api_key 48 | self._default_collection_name = collection_name 49 | self._embedding_provider = embedding_provider 50 | self._client = AsyncQdrantClient( 51 | location=qdrant_url, api_key=qdrant_api_key, path=qdrant_local_path 52 | ) 53 | self._field_indexes = field_indexes 54 | 55 | async def get_collection_names(self) -> list[str]: 56 | """ 57 | Get the names of all collections in the Qdrant server. 58 | :return: A list of collection names. 59 | """ 60 | response = await self._client.get_collections() 61 | return [collection.name for collection in response.collections] 62 | 63 | async def store(self, entry: Entry, *, collection_name: str | None = None): 64 | """ 65 | Store some information in the Qdrant collection, along with the specified metadata. 66 | :param entry: The entry to store in the Qdrant collection. 67 | :param collection_name: The name of the collection to store the information in, optional. If not provided, 68 | the default collection is used. 69 | """ 70 | collection_name = collection_name or self._default_collection_name 71 | assert collection_name is not None 72 | await self._ensure_collection_exists(collection_name) 73 | 74 | # Embed the document 75 | # ToDo: instead of embedding text explicitly, use `models.Document`, 76 | # it should unlock usage of server-side inference. 77 | embeddings = await self._embedding_provider.embed_documents([entry.content]) 78 | 79 | # Add to Qdrant 80 | vector_name = self._embedding_provider.get_vector_name() 81 | payload = {"document": entry.content, METADATA_PATH: entry.metadata} 82 | await self._client.upsert( 83 | collection_name=collection_name, 84 | points=[ 85 | models.PointStruct( 86 | id=uuid.uuid4().hex, 87 | vector={vector_name: embeddings[0]}, 88 | payload=payload, 89 | ) 90 | ], 91 | ) 92 | 93 | async def search( 94 | self, 95 | query: str, 96 | *, 97 | collection_name: str | None = None, 98 | limit: int = 10, 99 | query_filter: models.Filter | None = None, 100 | ) -> list[Entry]: 101 | """ 102 | Find points in the Qdrant collection. If there are no entries found, an empty list is returned. 103 | :param query: The query to use for the search. 104 | :param collection_name: The name of the collection to search in, optional. If not provided, 105 | the default collection is used. 106 | :param limit: The maximum number of entries to return. 107 | :param query_filter: The filter to apply to the query, if any. 108 | 109 | :return: A list of entries found. 110 | """ 111 | collection_name = collection_name or self._default_collection_name 112 | collection_exists = await self._client.collection_exists(collection_name) 113 | if not collection_exists: 114 | return [] 115 | 116 | # Embed the query 117 | # ToDo: instead of embedding text explicitly, use `models.Document`, 118 | # it should unlock usage of server-side inference. 119 | 120 | query_vector = await self._embedding_provider.embed_query(query) 121 | vector_name = self._embedding_provider.get_vector_name() 122 | 123 | # Search in Qdrant 124 | search_results = await self._client.query_points( 125 | collection_name=collection_name, 126 | query=query_vector, 127 | using=vector_name, 128 | limit=limit, 129 | query_filter=query_filter, 130 | ) 131 | 132 | return [ 133 | Entry( 134 | content=result.payload["document"], 135 | metadata=result.payload.get("metadata"), 136 | ) 137 | for result in search_results.points 138 | ] 139 | 140 | async def _ensure_collection_exists(self, collection_name: str): 141 | """ 142 | Ensure that the collection exists, creating it if necessary. 143 | :param collection_name: The name of the collection to ensure exists. 144 | """ 145 | collection_exists = await self._client.collection_exists(collection_name) 146 | if not collection_exists: 147 | # Create the collection with the appropriate vector size 148 | vector_size = self._embedding_provider.get_vector_size() 149 | 150 | # Use the vector name as defined in the embedding provider 151 | vector_name = self._embedding_provider.get_vector_name() 152 | await self._client.create_collection( 153 | collection_name=collection_name, 154 | vectors_config={ 155 | vector_name: models.VectorParams( 156 | size=vector_size, 157 | distance=models.Distance.COSINE, 158 | ) 159 | }, 160 | ) 161 | 162 | # Create payload indexes if configured 163 | 164 | if self._field_indexes: 165 | for field_name, field_type in self._field_indexes.items(): 166 | await self._client.create_payload_index( 167 | collection_name=collection_name, 168 | field_name=field_name, 169 | field_schema=field_type, 170 | ) 171 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/server.py: -------------------------------------------------------------------------------- 1 | from mcp_server_qdrant.mcp_server import QdrantMCPServer 2 | from mcp_server_qdrant.settings import ( 3 | EmbeddingProviderSettings, 4 | QdrantSettings, 5 | ToolSettings, 6 | ) 7 | 8 | mcp = QdrantMCPServer( 9 | tool_settings=ToolSettings(), 10 | qdrant_settings=QdrantSettings(), 11 | embedding_provider_settings=EmbeddingProviderSettings(), 12 | ) 13 | -------------------------------------------------------------------------------- /src/mcp_server_qdrant/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import BaseModel, Field, model_validator 4 | from pydantic_settings import BaseSettings 5 | 6 | from mcp_server_qdrant.embeddings.types import EmbeddingProviderType 7 | 8 | DEFAULT_TOOL_STORE_DESCRIPTION = ( 9 | "Keep the memory for later use, when you are asked to remember something." 10 | ) 11 | DEFAULT_TOOL_FIND_DESCRIPTION = ( 12 | "Look up memories in Qdrant. Use this tool when you need to: \n" 13 | " - Find memories by their content \n" 14 | " - Access memories for further analysis \n" 15 | " - Get some personal information about the user" 16 | ) 17 | 18 | METADATA_PATH = "metadata" 19 | 20 | 21 | class ToolSettings(BaseSettings): 22 | """ 23 | Configuration for all the tools. 24 | """ 25 | 26 | tool_store_description: str = Field( 27 | default=DEFAULT_TOOL_STORE_DESCRIPTION, 28 | validation_alias="TOOL_STORE_DESCRIPTION", 29 | ) 30 | tool_find_description: str = Field( 31 | default=DEFAULT_TOOL_FIND_DESCRIPTION, 32 | validation_alias="TOOL_FIND_DESCRIPTION", 33 | ) 34 | 35 | 36 | class EmbeddingProviderSettings(BaseSettings): 37 | """ 38 | Configuration for the embedding provider. 39 | """ 40 | 41 | provider_type: EmbeddingProviderType = Field( 42 | default=EmbeddingProviderType.FASTEMBED, 43 | validation_alias="EMBEDDING_PROVIDER", 44 | ) 45 | model_name: str = Field( 46 | default="sentence-transformers/all-MiniLM-L6-v2", 47 | validation_alias="EMBEDDING_MODEL", 48 | ) 49 | 50 | 51 | class FilterableField(BaseModel): 52 | name: str = Field(description="The name of the field payload field to filter on") 53 | description: str = Field( 54 | description="A description for the field used in the tool description" 55 | ) 56 | field_type: Literal["keyword", "integer", "float", "boolean"] = Field( 57 | description="The type of the field" 58 | ) 59 | condition: Literal["==", "!=", ">", ">=", "<", "<=", "any", "except"] | None = ( 60 | Field( 61 | default=None, 62 | description=( 63 | "The condition to use for the filter. If not provided, the field will be indexed, but no " 64 | "filter argument will be exposed to MCP tool." 65 | ), 66 | ) 67 | ) 68 | required: bool = Field( 69 | default=False, 70 | description="Whether the field is required for the filter.", 71 | ) 72 | 73 | 74 | class QdrantSettings(BaseSettings): 75 | """ 76 | Configuration for the Qdrant connector. 77 | """ 78 | 79 | location: str | None = Field(default=None, validation_alias="QDRANT_URL") 80 | api_key: str | None = Field(default=None, validation_alias="QDRANT_API_KEY") 81 | collection_name: str | None = Field( 82 | default=None, validation_alias="COLLECTION_NAME" 83 | ) 84 | local_path: str | None = Field(default=None, validation_alias="QDRANT_LOCAL_PATH") 85 | search_limit: int = Field(default=10, validation_alias="QDRANT_SEARCH_LIMIT") 86 | read_only: bool = Field(default=False, validation_alias="QDRANT_READ_ONLY") 87 | 88 | filterable_fields: list[FilterableField] | None = Field(default=None) 89 | 90 | allow_arbitrary_filter: bool = Field( 91 | default=False, validation_alias="QDRANT_ALLOW_ARBITRARY_FILTER" 92 | ) 93 | 94 | def filterable_fields_dict(self) -> dict[str, FilterableField]: 95 | if self.filterable_fields is None: 96 | return {} 97 | return {field.name: field for field in self.filterable_fields} 98 | 99 | def filterable_fields_dict_with_conditions(self) -> dict[str, FilterableField]: 100 | if self.filterable_fields is None: 101 | return {} 102 | return { 103 | field.name: field 104 | for field in self.filterable_fields 105 | if field.condition is not None 106 | } 107 | 108 | @model_validator(mode="after") 109 | def check_local_path_conflict(self) -> "QdrantSettings": 110 | if self.local_path: 111 | if self.location is not None or self.api_key is not None: 112 | raise ValueError( 113 | "If 'local_path' is set, 'location' and 'api_key' must be None." 114 | ) 115 | return self 116 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file can be empty, it just marks the directory as a Python package 2 | -------------------------------------------------------------------------------- /tests/test_fastembed_integration.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from fastembed import TextEmbedding 4 | 5 | from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider 6 | 7 | 8 | @pytest.mark.asyncio 9 | class TestFastEmbedProviderIntegration: 10 | """Integration tests for FastEmbedProvider.""" 11 | 12 | async def test_initialization(self): 13 | """Test that the provider can be initialized with a valid model.""" 14 | provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2") 15 | assert provider.model_name == "sentence-transformers/all-MiniLM-L6-v2" 16 | assert isinstance(provider.embedding_model, TextEmbedding) 17 | 18 | async def test_embed_documents(self): 19 | """Test that documents can be embedded.""" 20 | provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2") 21 | documents = ["This is a test document.", "This is another test document."] 22 | 23 | embeddings = await provider.embed_documents(documents) 24 | 25 | # Check that we got the right number of embeddings 26 | assert len(embeddings) == len(documents) 27 | 28 | # Check that embeddings have the expected shape 29 | # The exact dimension depends on the model, but should be consistent 30 | assert len(embeddings[0]) > 0 31 | assert all(len(embedding) == len(embeddings[0]) for embedding in embeddings) 32 | 33 | # Check that embeddings are different for different documents 34 | # Convert to numpy arrays for easier comparison 35 | embedding1 = np.array(embeddings[0]) 36 | embedding2 = np.array(embeddings[1]) 37 | assert not np.array_equal(embedding1, embedding2) 38 | 39 | async def test_embed_query(self): 40 | """Test that queries can be embedded.""" 41 | provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2") 42 | query = "This is a test query." 43 | 44 | embedding = await provider.embed_query(query) 45 | 46 | # Check that embedding has the expected shape 47 | assert len(embedding) > 0 48 | 49 | # Embed the same query again to check consistency 50 | embedding2 = await provider.embed_query(query) 51 | assert len(embedding) == len(embedding2) 52 | 53 | # The embeddings should be identical for the same input 54 | np.testing.assert_array_almost_equal(np.array(embedding), np.array(embedding2)) 55 | 56 | async def test_get_vector_name(self): 57 | """Test that the vector name is generated correctly.""" 58 | provider = FastEmbedProvider("sentence-transformers/all-MiniLM-L6-v2") 59 | vector_name = provider.get_vector_name() 60 | 61 | # Check that the vector name follows the expected format 62 | assert vector_name.startswith("fast-") 63 | assert "minilm" in vector_name.lower() 64 | -------------------------------------------------------------------------------- /tests/test_qdrant_integration.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | 5 | from mcp_server_qdrant.embeddings.fastembed import FastEmbedProvider 6 | from mcp_server_qdrant.qdrant import Entry, QdrantConnector 7 | 8 | 9 | @pytest.fixture 10 | async def embedding_provider(): 11 | """Fixture to provide a FastEmbed embedding provider.""" 12 | return FastEmbedProvider(model_name="sentence-transformers/all-MiniLM-L6-v2") 13 | 14 | 15 | @pytest.fixture 16 | async def qdrant_connector(embedding_provider): 17 | """Fixture to provide a QdrantConnector with in-memory Qdrant client.""" 18 | # Use a random collection name to avoid conflicts between tests 19 | collection_name = f"test_collection_{uuid.uuid4().hex}" 20 | 21 | # Create connector with in-memory Qdrant 22 | connector = QdrantConnector( 23 | qdrant_url=":memory:", 24 | qdrant_api_key=None, 25 | collection_name=collection_name, 26 | embedding_provider=embedding_provider, 27 | ) 28 | 29 | yield connector 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_store_and_search(qdrant_connector): 34 | """Test storing an entry and then searching for it.""" 35 | # Store a test entry 36 | test_entry = Entry( 37 | content="The quick brown fox jumps over the lazy dog", 38 | metadata={"source": "test", "importance": "high"}, 39 | ) 40 | await qdrant_connector.store(test_entry) 41 | 42 | # Search for the entry 43 | results = await qdrant_connector.search("fox jumps") 44 | 45 | # Verify results 46 | assert len(results) == 1 47 | assert results[0].content == test_entry.content 48 | assert results[0].metadata == test_entry.metadata 49 | 50 | 51 | @pytest.mark.asyncio 52 | async def test_search_empty_collection(qdrant_connector): 53 | """Test searching in an empty collection.""" 54 | # Search in an empty collection 55 | results = await qdrant_connector.search("test query") 56 | 57 | # Verify results 58 | assert len(results) == 0 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_multiple_entries(qdrant_connector): 63 | """Test storing and searching multiple entries.""" 64 | # Store multiple entries 65 | entries = [ 66 | Entry( 67 | content="Python is a programming language", 68 | metadata={"topic": "programming"}, 69 | ), 70 | Entry(content="The Eiffel Tower is in Paris", metadata={"topic": "landmarks"}), 71 | Entry(content="Machine learning is a subset of AI", metadata={"topic": "AI"}), 72 | ] 73 | 74 | for entry in entries: 75 | await qdrant_connector.store(entry) 76 | 77 | # Search for programming-related entries 78 | programming_results = await qdrant_connector.search("Python programming") 79 | assert len(programming_results) > 0 80 | assert any("Python" in result.content for result in programming_results) 81 | 82 | # Search for landmark-related entries 83 | landmark_results = await qdrant_connector.search("Eiffel Tower Paris") 84 | assert len(landmark_results) > 0 85 | assert any("Eiffel" in result.content for result in landmark_results) 86 | 87 | # Search for AI-related entries 88 | ai_results = await qdrant_connector.search( 89 | "artificial intelligence machine learning" 90 | ) 91 | assert len(ai_results) > 0 92 | assert any("machine learning" in result.content.lower() for result in ai_results) 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_ensure_collection_exists(qdrant_connector): 97 | """Test that the collection is created if it doesn't exist.""" 98 | # The collection shouldn't exist yet 99 | assert not await qdrant_connector._client.collection_exists( 100 | qdrant_connector._default_collection_name 101 | ) 102 | 103 | # Storing an entry should create the collection 104 | test_entry = Entry(content="Test content") 105 | await qdrant_connector.store(test_entry) 106 | 107 | # Now the collection should exist 108 | assert await qdrant_connector._client.collection_exists( 109 | qdrant_connector._default_collection_name 110 | ) 111 | 112 | 113 | @pytest.mark.asyncio 114 | async def test_metadata_handling(qdrant_connector): 115 | """Test that metadata is properly stored and retrieved.""" 116 | # Store entries with different metadata 117 | metadata1 = {"source": "book", "author": "Jane Doe", "year": 2023} 118 | metadata2 = {"source": "article", "tags": ["science", "research"]} 119 | 120 | await qdrant_connector.store( 121 | Entry(content="Content with structured metadata", metadata=metadata1) 122 | ) 123 | await qdrant_connector.store( 124 | Entry(content="Content with list in metadata", metadata=metadata2) 125 | ) 126 | 127 | # Search and verify metadata is preserved 128 | results = await qdrant_connector.search("metadata") 129 | 130 | assert len(results) == 2 131 | 132 | # Check that both metadata objects are present in the results 133 | found_metadata1 = False 134 | found_metadata2 = False 135 | 136 | for result in results: 137 | if result.metadata.get("source") == "book": 138 | assert result.metadata.get("author") == "Jane Doe" 139 | assert result.metadata.get("year") == 2023 140 | found_metadata1 = True 141 | elif result.metadata.get("source") == "article": 142 | assert "science" in result.metadata.get("tags", []) 143 | assert "research" in result.metadata.get("tags", []) 144 | found_metadata2 = True 145 | 146 | assert found_metadata1 147 | assert found_metadata2 148 | 149 | 150 | @pytest.mark.asyncio 151 | async def test_entry_without_metadata(qdrant_connector): 152 | """Test storing and retrieving entries without metadata.""" 153 | # Store an entry without metadata 154 | await qdrant_connector.store(Entry(content="Entry without metadata")) 155 | 156 | # Search and verify 157 | results = await qdrant_connector.search("without metadata") 158 | 159 | assert len(results) == 1 160 | assert results[0].content == "Entry without metadata" 161 | assert results[0].metadata is None 162 | 163 | 164 | @pytest.mark.asyncio 165 | async def test_custom_collection_store_and_search(qdrant_connector): 166 | """Test storing and searching in a custom collection.""" 167 | # Define a custom collection name 168 | custom_collection = f"custom_collection_{uuid.uuid4().hex}" 169 | 170 | # Store a test entry in the custom collection 171 | test_entry = Entry( 172 | content="This is stored in a custom collection", 173 | metadata={"custom": True}, 174 | ) 175 | await qdrant_connector.store(test_entry, collection_name=custom_collection) 176 | 177 | # Search in the custom collection 178 | results = await qdrant_connector.search( 179 | "custom collection", collection_name=custom_collection 180 | ) 181 | 182 | # Verify results 183 | assert len(results) == 1 184 | assert results[0].content == test_entry.content 185 | assert results[0].metadata == test_entry.metadata 186 | 187 | # Verify the entry is not in the default collection 188 | default_results = await qdrant_connector.search("custom collection") 189 | assert len(default_results) == 0 190 | 191 | 192 | @pytest.mark.asyncio 193 | async def test_multiple_collections(qdrant_connector): 194 | """Test using multiple collections with the same connector.""" 195 | # Define two custom collection names 196 | collection_a = f"collection_a_{uuid.uuid4().hex}" 197 | collection_b = f"collection_b_{uuid.uuid4().hex}" 198 | 199 | # Store entries in different collections 200 | entry_a = Entry( 201 | content="This belongs to collection A", metadata={"collection": "A"} 202 | ) 203 | entry_b = Entry( 204 | content="This belongs to collection B", metadata={"collection": "B"} 205 | ) 206 | entry_default = Entry(content="This belongs to the default collection") 207 | 208 | await qdrant_connector.store(entry_a, collection_name=collection_a) 209 | await qdrant_connector.store(entry_b, collection_name=collection_b) 210 | await qdrant_connector.store(entry_default) 211 | 212 | # Search in collection A 213 | results_a = await qdrant_connector.search("belongs", collection_name=collection_a) 214 | assert len(results_a) == 1 215 | assert results_a[0].content == entry_a.content 216 | 217 | # Search in collection B 218 | results_b = await qdrant_connector.search("belongs", collection_name=collection_b) 219 | assert len(results_b) == 1 220 | assert results_b[0].content == entry_b.content 221 | 222 | # Search in default collection 223 | results_default = await qdrant_connector.search("belongs") 224 | assert len(results_default) == 1 225 | assert results_default[0].content == entry_default.content 226 | 227 | 228 | @pytest.mark.asyncio 229 | async def test_nonexistent_collection_search(qdrant_connector): 230 | """Test searching in a collection that doesn't exist.""" 231 | # Search in a collection that doesn't exist 232 | nonexistent_collection = f"nonexistent_{uuid.uuid4().hex}" 233 | results = await qdrant_connector.search( 234 | "test query", collection_name=nonexistent_collection 235 | ) 236 | 237 | # Verify results 238 | assert len(results) == 0 239 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mcp_server_qdrant.embeddings.types import EmbeddingProviderType 4 | from mcp_server_qdrant.settings import ( 5 | DEFAULT_TOOL_FIND_DESCRIPTION, 6 | DEFAULT_TOOL_STORE_DESCRIPTION, 7 | EmbeddingProviderSettings, 8 | QdrantSettings, 9 | ToolSettings, 10 | ) 11 | 12 | 13 | class TestQdrantSettings: 14 | def test_default_values(self): 15 | """Test that required fields raise errors when not provided.""" 16 | 17 | # Should not raise error because there are no required fields 18 | QdrantSettings() 19 | 20 | def test_minimal_config(self, monkeypatch): 21 | """Test loading minimal configuration from environment variables.""" 22 | monkeypatch.setenv("QDRANT_URL", "http://localhost:6333") 23 | monkeypatch.setenv("COLLECTION_NAME", "test_collection") 24 | 25 | settings = QdrantSettings() 26 | assert settings.location == "http://localhost:6333" 27 | assert settings.collection_name == "test_collection" 28 | assert settings.api_key is None 29 | assert settings.local_path is None 30 | 31 | def test_full_config(self, monkeypatch): 32 | """Test loading full configuration from environment variables.""" 33 | monkeypatch.setenv("QDRANT_URL", "http://qdrant.example.com:6333") 34 | monkeypatch.setenv("QDRANT_API_KEY", "test_api_key") 35 | monkeypatch.setenv("COLLECTION_NAME", "my_memories") 36 | monkeypatch.setenv("QDRANT_SEARCH_LIMIT", "15") 37 | monkeypatch.setenv("QDRANT_READ_ONLY", "1") 38 | 39 | settings = QdrantSettings() 40 | assert settings.location == "http://qdrant.example.com:6333" 41 | assert settings.api_key == "test_api_key" 42 | assert settings.collection_name == "my_memories" 43 | assert settings.search_limit == 15 44 | assert settings.read_only is True 45 | 46 | def test_local_path_config(self, monkeypatch): 47 | """Test loading local path configuration from environment variables.""" 48 | monkeypatch.setenv("QDRANT_LOCAL_PATH", "/path/to/local/qdrant") 49 | 50 | settings = QdrantSettings() 51 | assert settings.local_path == "/path/to/local/qdrant" 52 | 53 | def test_local_path_is_exclusive_with_url(self, monkeypatch): 54 | """Test that local path cannot be set if Qdrant URL is provided.""" 55 | monkeypatch.setenv("QDRANT_URL", "http://localhost:6333") 56 | monkeypatch.setenv("QDRANT_LOCAL_PATH", "/path/to/local/qdrant") 57 | 58 | with pytest.raises(ValueError): 59 | QdrantSettings() 60 | 61 | monkeypatch.delenv("QDRANT_URL", raising=False) 62 | monkeypatch.setenv("QDRANT_API_KEY", "test_api_key") 63 | with pytest.raises(ValueError): 64 | QdrantSettings() 65 | 66 | 67 | class TestEmbeddingProviderSettings: 68 | def test_default_values(self): 69 | """Test default values are set correctly.""" 70 | settings = EmbeddingProviderSettings() 71 | assert settings.provider_type == EmbeddingProviderType.FASTEMBED 72 | assert settings.model_name == "sentence-transformers/all-MiniLM-L6-v2" 73 | 74 | def test_custom_values(self, monkeypatch): 75 | """Test loading custom values from environment variables.""" 76 | monkeypatch.setenv("EMBEDDING_MODEL", "custom_model") 77 | settings = EmbeddingProviderSettings() 78 | assert settings.provider_type == EmbeddingProviderType.FASTEMBED 79 | assert settings.model_name == "custom_model" 80 | 81 | 82 | class TestToolSettings: 83 | def test_default_values(self): 84 | """Test that default values are set correctly when no env vars are provided.""" 85 | settings = ToolSettings() 86 | assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION 87 | assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION 88 | 89 | def test_custom_store_description(self, monkeypatch): 90 | """Test loading custom store description from environment variable.""" 91 | monkeypatch.setenv("TOOL_STORE_DESCRIPTION", "Custom store description") 92 | settings = ToolSettings() 93 | assert settings.tool_store_description == "Custom store description" 94 | assert settings.tool_find_description == DEFAULT_TOOL_FIND_DESCRIPTION 95 | 96 | def test_custom_find_description(self, monkeypatch): 97 | """Test loading custom find description from environment variable.""" 98 | monkeypatch.setenv("TOOL_FIND_DESCRIPTION", "Custom find description") 99 | settings = ToolSettings() 100 | assert settings.tool_store_description == DEFAULT_TOOL_STORE_DESCRIPTION 101 | assert settings.tool_find_description == "Custom find description" 102 | 103 | def test_all_custom_values(self, monkeypatch): 104 | """Test loading all custom values from environment variables.""" 105 | monkeypatch.setenv("TOOL_STORE_DESCRIPTION", "Custom store description") 106 | monkeypatch.setenv("TOOL_FIND_DESCRIPTION", "Custom find description") 107 | settings = ToolSettings() 108 | assert settings.tool_store_description == "Custom store description" 109 | assert settings.tool_find_description == "Custom find description" 110 | --------------------------------------------------------------------------------