From 04bc2325f2be0134d18f2885dfc4591fa47b34fb Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 10 May 2026 14:19:11 +0200 Subject: [PATCH] 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):