From f2d4982b2ff8327bd5e6d9eb36944439a02aa1be Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 10 May 2026 14:03:48 +0200 Subject: [PATCH 1/4] fix(qdrant): use collection_exists for startup probe (multi-tenant safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup path in `get_qdrant_client()` calls `get_collections()` to check whether the configured collection already exists. That's a cluster-wide list operation; in managed multi-tenant Qdrant Cloud deployments where each tenant's JWT is scoped to a single collection (by design — `access: [{"collection": "tenant_", "access": "rw"}]`), the call returns `403 Forbidden` and the FastAPI lifespan crashes: qdrant_client.http.exceptions.UnexpectedResponse: 403 (Forbidden) raw response: {"error":"forbidden"} RuntimeError: Cannot start vector sync - Qdrant initialization failed Switching to `collection_exists(collection_name)` (per-collection HEAD-style probe) only requires access to the named collection, which the tenant JWT has. Single-tenant deployments using an admin/master key are unaffected — they had access to both forms; this picks the narrower one. Doesn't change creation semantics: when the collection isn't present the code path still calls `create_collection`. In a managed setup where the collection is pre-provisioned by an external admin (e.g., the Astrolabe Cloud control plane's create-tenant workflow), that branch never fires for an existing tenant; cold-start tenants get their collection created by the workflow before the Pod boots. Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/vector/qdrant_client.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/nextcloud_mcp_server/vector/qdrant_client.py b/nextcloud_mcp_server/vector/qdrant_client.py index b2aa0e65..77ccf26a 100644 --- a/nextcloud_mcp_server/vector/qdrant_client.py +++ b/nextcloud_mcp_server/vector/qdrant_client.py @@ -71,12 +71,21 @@ async def get_qdrant_client() -> AsyncQdrantClient: expected_dimension = embedding_service.get_dimension() - # Explicitly check if collection exists + # Explicitly check if collection exists. + # + # Use `collection_exists(name)` (per-collection HEAD-style probe) + # rather than `get_collections()` (cluster-wide list). In managed + # multi-tenant Qdrant Cloud setups, per-tenant JWTs are scoped + # to a single collection and `get_collections()` returns 403 + # Forbidden by design — listing other tenants' collections would + # be a security regression. `collection_exists` only requires + # access to the named collection, which the tenant JWT has. logger.debug(f"Checking if collection '{collection_name}' exists...") - collections = await _qdrant_client.get_collections() - collection_names = [c.name for c in collections.collections] + collection_present = await _qdrant_client.collection_exists( + collection_name=collection_name + ) - if collection_name in collection_names: + if collection_present: # Collection exists - validate dimensions logger.debug( f"Collection '{collection_name}' found, validating dimensions..." From 2ac0a926c00f13bd46ecf33b3ada2e280a7b47bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 10 May 2026 12:04:53 +0000 Subject: [PATCH 2/4] =?UTF-8?q?bump:=20version=200.83.2=20=E2=86=92=200.83?= =?UTF-8?q?.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e2eab2..24af50e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Nextcloud MCP Server will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). +## v0.83.3 (2026-05-10) + +### Fix + +- **qdrant**: use collection_exists for startup probe (multi-tenant safe) + ## v0.83.2 (2026-05-09) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 5a8101de..528117ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.83.2" +version = "0.83.3" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" authors = [ {name = "Chris Coutinho", email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 9082741f..970650e7 100644 --- a/uv.lock +++ b/uv.lock @@ -2123,7 +2123,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.83.2" +version = "0.83.3" source = { editable = "." } dependencies = [ { name = "aiosqlite" }, From 04bc2325f2be0134d18f2885dfc4591fa47b34fb Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 10 May 2026 14:19:11 +0200 Subject: [PATCH 3/4] fix(qdrant): use get_collection for startup probe (multi-tenant safe, take 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #778 — `collection_exists()` is also denied by Qdrant Cloud on a collection-scoped JWT, so the multi-tenant fix needs to go one step further: use `get_collection(name)` (the underlying GET `/collections/{name}` call) and treat a 404 `UnexpectedResponse` as the "doesn't exist" signal. That endpoint is the only existence-probe Qdrant permits on a collection-scoped JWT — listing or probing collection metadata cluster-wide is a tenant-isolation boundary by design. Hit during Astrolabe Cloud smoke17 with the post-#778 image: qdrant_client.http.exceptions.UnexpectedResponse: 403 (Forbidden) raw response: {"error":"forbidden"} File "qdrant_client.py", line 84, in get_qdrant_client collection_present = await _qdrant_client.collection_exists(...) Folds the existence check into the same `get_collection()` call that already runs immediately afterward for dimension validation, so the new path is also one fewer round-trip on the happy path. Cold-start (collection genuinely missing) behavior is unchanged: 404 → `collection_info` is None → fall through to `create_collection()`. Whether `create_collection` succeeds is an orthogonal concern (managed multi-tenant setups pre-provision collections externally; admin-key single-tenant setups can create on the fly). Co-Authored-By: Claude Opus 4.7 (1M context) --- nextcloud_mcp_server/vector/qdrant_client.py | 36 ++++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/nextcloud_mcp_server/vector/qdrant_client.py b/nextcloud_mcp_server/vector/qdrant_client.py index 77ccf26a..efd9e0a8 100644 --- a/nextcloud_mcp_server/vector/qdrant_client.py +++ b/nextcloud_mcp_server/vector/qdrant_client.py @@ -3,6 +3,7 @@ import logging from qdrant_client import AsyncQdrantClient, models +from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.models import Distance, VectorParams from nextcloud_mcp_server.config import get_settings @@ -71,26 +72,33 @@ async def get_qdrant_client() -> AsyncQdrantClient: expected_dimension = embedding_service.get_dimension() - # Explicitly check if collection exists. + # Existence check folded into the get_collection() call. # - # Use `collection_exists(name)` (per-collection HEAD-style probe) - # rather than `get_collections()` (cluster-wide list). In managed - # multi-tenant Qdrant Cloud setups, per-tenant JWTs are scoped - # to a single collection and `get_collections()` returns 403 - # Forbidden by design — listing other tenants' collections would - # be a security regression. `collection_exists` only requires - # access to the named collection, which the tenant JWT has. - logger.debug(f"Checking if collection '{collection_name}' exists...") - collection_present = await _qdrant_client.collection_exists( - collection_name=collection_name - ) + # In managed multi-tenant Qdrant Cloud setups, per-tenant JWTs are + # scoped to a single collection (`access: [{"collection": "...", + # "access": "rw"}]`) and Qdrant denies the cluster-level meta + # endpoints `GET /collections` (used by `get_collections()`) and + # `GET /collections/{name}/exists` (used by `collection_exists()`) + # with 403 Forbidden — by design, since listing or probing + # collections cluster-wide is a tenant-isolation boundary. + # `GET /collections/{name}` (the underlying call for + # `get_collection()`) is the only existence-probe permitted on a + # collection-scoped JWT — it returns 200 with the collection + # detail on hit and 404 on miss. + logger.debug(f"Fetching collection '{collection_name}' details...") + collection_info = None + try: + collection_info = await _qdrant_client.get_collection(collection_name) + except UnexpectedResponse as exc: + if exc.status_code != 404: + raise + logger.debug(f"Collection '{collection_name}' not found (404).") - if collection_present: + if collection_info is not None: # Collection exists - validate dimensions logger.debug( f"Collection '{collection_name}' found, validating dimensions..." ) - collection_info = await _qdrant_client.get_collection(collection_name) # Handle both named vectors (dict) and legacy single vector vectors = collection_info.config.params.vectors if isinstance(vectors, dict): From dde4b14784853796bb0adea60c7e07768cfe65b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 10 May 2026 12:20:16 +0000 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.83.3=20=E2=86=92=200.83?= =?UTF-8?q?.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24af50e8..72d17275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the Nextcloud MCP Server will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [PEP 440](https://peps.python.org/pep-0440/). +## v0.83.4 (2026-05-10) + +### Fix + +- **qdrant**: use get_collection for startup probe (multi-tenant safe, take 2) + ## v0.83.3 (2026-05-10) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 528117ac..f813b5ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nextcloud-mcp-server" -version = "0.83.3" +version = "0.83.4" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" authors = [ {name = "Chris Coutinho", email = "chris@coutinho.io"} diff --git a/uv.lock b/uv.lock index 970650e7..abcf9bd9 100644 --- a/uv.lock +++ b/uv.lock @@ -2123,7 +2123,7 @@ wheels = [ [[package]] name = "nextcloud-mcp-server" -version = "0.83.3" +version = "0.83.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },