diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index 8b45077..2803e7a 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier): try: # Introspection requires client authentication + client_id = self.settings.oidc_client_id + client_secret = self.settings.oidc_client_secret + assert client_id is not None and client_secret is not None response = await self.http_client.post( self.introspection_uri, data={"token": token}, - auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret), + auth=(client_id, client_secret), ) if response.status_code == 200: diff --git a/nextcloud_mcp_server/auth/webhook_routes.py b/nextcloud_mcp_server/auth/webhook_routes.py index a693930..35446e0 100644 --- a/nextcloud_mcp_server/auth/webhook_routes.py +++ b/nextcloud_mcp_server/auth/webhook_routes.py @@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient: raise RuntimeError("BasicAuth credentials not configured") assert nextcloud_host is not None # Type narrowing for type checker + assert username is not None and password is not None # Type narrowing return httpx.AsyncClient( base_url=nextcloud_host, auth=(username, password), diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index e8b3f6f..72abc6a 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -1174,7 +1174,9 @@ class WebDAVClient(BaseNextcloudClient): if display_name_elem is not None and display_name_elem.text == tag_name: tag_info = { - "id": int(tag_id_elem.text) if tag_id_elem is not None else None, + "id": int(tag_id_elem.text) + if tag_id_elem is not None and tag_id_elem.text is not None + else None, "name": display_name_elem.text, "userVisible": user_visible_elem.text.lower() == "true" if user_visible_elem is not None @@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient): ) file_info = { - "id": int(fileid_elem.text) if fileid_elem is not None else None, + "id": int(fileid_elem.text) + if fileid_elem is not None and fileid_elem.text is not None + else None, "path": path, "name": displayname_elem.text if displayname_elem is not None diff --git a/nextcloud_mcp_server/observability/logging_config.py b/nextcloud_mcp_server/observability/logging_config.py index b1bd3f4..1bde143 100644 --- a/nextcloud_mcp_server/observability/logging_config.py +++ b/nextcloud_mcp_server/observability/logging_config.py @@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter): def add_fields( self, - log_record: dict[str, Any], + log_data: dict[str, Any], record: logging.LogRecord, message_dict: dict[str, Any], ) -> None: @@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter): Add custom fields to the log record, including trace context. Args: - log_record: Dictionary to be serialized as JSON + log_data: Dictionary to be serialized as JSON record: LogRecord instance message_dict: Dictionary of extra fields from log call """ # Call parent to add standard fields - super().add_fields(log_record, record, message_dict) + super().add_fields(log_data, record, message_dict) # Add trace context if available trace_context = get_trace_context() if trace_context: - log_record["trace_id"] = trace_context.get("trace_id") - log_record["span_id"] = trace_context.get("span_id") + log_data["trace_id"] = trace_context.get("trace_id") + log_data["span_id"] = trace_context.get("span_id") # Add standard fields with consistent naming - log_record["timestamp"] = self.formatTime(record) - log_record["level"] = record.levelname - log_record["logger"] = record.name - log_record["message"] = record.getMessage() + log_data["timestamp"] = self.formatTime(record) + log_data["level"] = record.levelname + log_data["logger"] = record.name + log_data["message"] = record.getMessage() # Include exception info if present if record.exc_info: - log_record["exception"] = self.formatException(record.exc_info) + log_data["exception"] = self.formatException(record.exc_info) class TraceContextTextFormatter(logging.Formatter): diff --git a/nextcloud_mcp_server/providers/openai.py b/nextcloud_mcp_server/providers/openai.py index 294f261..201c1c2 100644 --- a/nextcloud_mcp_server/providers/openai.py +++ b/nextcloud_mcp_server/providers/openai.py @@ -140,6 +140,7 @@ class OpenAIProvider(Provider): "Embedding not supported - no embedding_model configured" ) + assert self.embedding_model is not None # Type narrowing response = await self.client.embeddings.create( input=text, model=self.embedding_model, @@ -204,6 +205,7 @@ class OpenAIProvider(Provider): @retry_on_rate_limit async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]: """Make a single batch embedding request with retry logic.""" + assert self.embedding_model is not None # Type narrowing response = await self.client.embeddings.create( input=batch, model=self.embedding_model, diff --git a/nextcloud_mcp_server/search/algorithms.py b/nextcloud_mcp_server/search/algorithms.py index ff86ca3..7e74424 100644 --- a/nextcloud_mcp_server/search/algorithms.py +++ b/nextcloud_mcp_server/search/algorithms.py @@ -108,8 +108,8 @@ async def get_indexed_doc_types(user_id: str) -> set[str]: with_vectors=False, # Don't need vectors for type discovery ) - doc_types = { - point.payload.get("doc_type") + doc_types: set[str] = { + str(point.payload.get("doc_type")) for point in scroll_results if point.payload.get("doc_type") } diff --git a/nextcloud_mcp_server/search/bm25_hybrid.py b/nextcloud_mcp_server/search/bm25_hybrid.py index a10e3db..013fb78 100644 --- a/nextcloud_mcp_server/search/bm25_hybrid.py +++ b/nextcloud_mcp_server/search/bm25_hybrid.py @@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm): results = [] for result in search_response.points: + if result.payload is None: + continue # doc_id can be int (notes) or str (files - file paths) doc_id = result.payload["doc_id"] doc_type = result.payload.get("doc_type", "note") diff --git a/nextcloud_mcp_server/search/semantic.py b/nextcloud_mcp_server/search/semantic.py index 9c17c76..a4a449e 100644 --- a/nextcloud_mcp_server/search/semantic.py +++ b/nextcloud_mcp_server/search/semantic.py @@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm): results = [] for result in search_response.points: + if result.payload is None: + continue # doc_id can be int (notes) or str (files - file paths) doc_id = result.payload["doc_id"] doc_type = result.payload.get("doc_type", "note") diff --git a/nextcloud_mcp_server/vector/scanner.py b/nextcloud_mcp_server/vector/scanner.py index 619ed29..068d616 100644 --- a/nextcloud_mcp_server/vector/scanner.py +++ b/nextcloud_mcp_server/vector/scanner.py @@ -206,7 +206,11 @@ async def scan_user_documents( limit=10000, ) - indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]} + indexed_doc_ids = { + point.payload["doc_id"] + for point in (scroll_result[0] or []) + if point.payload is not None + } logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant") @@ -376,7 +380,9 @@ async def scan_user_documents( ) indexed_file_ids = { - point.payload["doc_id"] for point in file_scroll_result[0] + point.payload["doc_id"] + for point in (file_scroll_result[0] or []) + if point.payload is not None } logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant") @@ -611,7 +617,11 @@ async def scan_news_items( with_vectors=False, limit=10000, ) - indexed_item_ids = {point.payload["doc_id"] for point in scroll_result[0]} + indexed_item_ids = { + point.payload["doc_id"] + for point in (scroll_result[0] or []) + if point.payload is not None + } logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant") # Fetch all items (News app caps at ~200 per feed via auto-purge) diff --git a/tests/unit/client/test_webdav.py b/tests/unit/client/test_webdav.py index f802441..cb105cc 100644 --- a/tests/unit/client/test_webdav.py +++ b/tests/unit/client/test_webdav.py @@ -189,25 +189,14 @@ async def test_get_file_info_returns_none_for_missing_file(mocker): @pytest.mark.unit async def test_create_tag_creates_system_tag(mocker): - """Test that create_tag creates a system tag via OCS API.""" + """Test that create_tag creates a system tag via WebDAV.""" mock_http_client = AsyncMock() client = WebDAVClient(mock_http_client, "testuser") - # Mock OCS response + # Mock WebDAV response with Content-Location header mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.json = mocker.Mock( - return_value={ - "ocs": { - "data": { - "id": 42, - "name": "vector-index", - "userVisible": True, - "userAssignable": True, - } - } - } - ) + mock_response.status_code = 201 + mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"} mock_response.raise_for_status = mocker.Mock() mock_http_client.post = AsyncMock(return_value=mock_response) @@ -224,8 +213,10 @@ async def test_create_tag_creates_system_tag(mocker): # Verify API call mock_http_client.post.assert_called_once() call_args = mock_http_client.post.call_args - assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags" + assert call_args[0][0] == "/remote.php/dav/systemtags/" assert call_args[1]["json"]["name"] == "vector-index" + assert call_args[1]["json"]["userVisible"] is True + assert call_args[1]["json"]["userAssignable"] is True @pytest.mark.unit