Files
mcp-nextcloud/nextcloud_mcp_server/client/__init__.py
T
Chris CoutinhoandClaude Opus 4.7 43c6788555 feat(vector): expand tagged directories for include + apply EXCLUDED_TAGS in scanner
NextcloudClient.find_files_by_tag now mirrors the directory semantics
already used by the exclusion path (issue #710): when a tagged item is
a folder, walk its descendants via WebDAV SEARCH (Depth: infinity) and
include any files matching the MIME filter. Without this, tagging the
root of a corpus with `vector-index` indexed nothing because the tag
applies to the directory only, not to its children.

The vector scanner additionally consults EXCLUDED_TAGS now, so a folder
marked off-limits is skipped even if it (or an ancestor) carries the
include tag — defense-in-depth, matching the "exclusion wins" contract
already enforced by the MCP file tools.

Also addressed a recurring memory-style nit: pre-existing f-string log
lines in find_files_by_tag were converted to lazy %-style.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:29:37 +02:00

342 lines
12 KiB
Python

import logging
import os
from email.utils import parsedate_to_datetime
from httpx import (
AsyncBaseTransport,
AsyncClient,
Auth,
BasicAuth,
Request,
Response,
Timeout,
)
from ..controllers.notes_search import NotesSearchController
from ..http import nextcloud_httpx_transport
from .calendar import CalendarClient
from .collectives import CollectivesClient
from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .news import NewsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
from .talk import TalkClient
from .users import UsersClient
from .webdav import WebDAVClient
from .webhooks import WebhooksClient
logger = logging.getLogger(__name__)
async def log_request(request: Request):
logger.debug(
"Request event hook: %s %s - Waiting for content",
request.method,
request.url,
)
logger.debug("Request body: %s", request.content)
logger.debug("Headers: %s", request.headers)
async def log_response(response: Response):
await response.aread()
logger.debug("Response [%s] %s", response.status_code, response.text)
def _normalise_search_result(item: dict) -> dict:
"""Normalise a webdav.search_files item to the get_files_by_tag shape.
``WebDAVClient.search_files`` and ``WebDAVClient.get_files_by_tag`` both
return per-file dicts but with subtly different keys (``file_id`` vs
``id``) and path conventions (no leading slash vs leading slash). This
helper makes a search result interchangeable with a tagged-file result
so callers (notably the vector scanner) can consume both via one shape.
"""
path = item.get("path", "")
if path and not path.startswith("/"):
path = "/" + path
last_modified_timestamp = item.get("last_modified_timestamp")
last_modified = item.get("last_modified")
if last_modified_timestamp is None and last_modified:
try:
last_modified_timestamp = int(
parsedate_to_datetime(last_modified).timestamp()
)
except (TypeError, ValueError):
last_modified_timestamp = None
file_id = item.get("file_id") if item.get("file_id") is not None else item.get("id")
return {
"id": file_id,
"path": path,
"name": item.get("name") or (path.rsplit("/", 1)[-1] if path else ""),
"size": item.get("size", 0),
"content_type": item.get("content_type", ""),
"last_modified": last_modified,
"last_modified_timestamp": last_modified_timestamp,
"etag": item.get("etag"),
"is_directory": item.get("is_directory", False),
}
class AsyncDisableCookieTransport(AsyncBaseTransport):
"""This Transport disable cookies from accumulating in the httpx AsyncClient
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
"""
def __init__(self, transport: AsyncBaseTransport):
self.transport = transport
async def handle_async_request(self, request: Request) -> Response:
response = await self.transport.handle_async_request(request)
response.headers.pop("set-cookie", None)
return response
class NextcloudClient:
"""Main Nextcloud client that orchestrates all app clients."""
def __init__(
self,
base_url: str,
username: str,
auth: Auth | None = None,
*,
password: str | None = None,
token: str | None = None,
):
self.username = username
self._client = AsyncClient(
base_url=base_url,
auth=auth,
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(timeout=30, connect=5),
)
# Initialize app clients
self.notes = NotesClient(self._client, username)
self.webdav = WebDAVClient(self._client, username)
self.tables = TablesClient(self._client, username)
# CalendarClient takes raw credentials so caldav (which uses niquests as
# its preferred backend in v3.x) builds a backend-compatible auth object
# itself — passing httpx.BasicAuth here breaks under niquests (#731).
self.calendar = CalendarClient(
base_url, username, password=password, token=token
)
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.collectives = CollectivesClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username)
self.talk = TalkClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(self._client, username)
self.webhooks = WebhooksClient(self._client, username)
# Initialize controllers
self._notes_search = NotesSearchController()
@classmethod
def from_env(cls):
logger.info("Creating NC Client using env vars")
host = os.environ["NEXTCLOUD_HOST"]
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
# Pass username to constructor
return cls(
base_url=host,
username=username,
auth=BasicAuth(username, password),
password=password,
)
@classmethod
def from_token(cls, base_url: str, token: str, username: str):
"""Create NextcloudClient with OAuth bearer token.
Args:
base_url: Nextcloud base URL
token: OAuth access token
username: Nextcloud username
Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth # noqa: PLC0415
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(
base_url=base_url,
username=username,
auth=BearerAuth(token),
token=token,
)
async def capabilities(self):
response = await self._client.get(
"/ocs/v2.php/cloud/capabilities",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
return response.json()
async def notes_search_notes(self, *, query: str):
"""Search notes using token-based matching with relevance ranking."""
all_notes = self.notes.get_all_notes()
return await self._notes_search.search_notes(all_notes, query)
async def find_files_by_tag(
self, tag_name: str, mime_type_filter: str | None = None
) -> list[dict]:
"""Find files by system tag name, optionally filtered by MIME type.
This method coordinates tag lookup and file retrieval via WebDAV:
1. Look up the tag ID by name
2. Get all entries (files and directories) with that tag via REPORT
3. For each tagged directory, walk descendants matching ``mime_type_filter``
via WebDAV SEARCH (``Depth: infinity``) so a tag on a folder applies
to every matching file beneath it. Mirrors the directory semantics
of :mod:`nextcloud_mcp_server.server.tag_exclusion` (issue #710).
4. Dedupe by file id — a file directly tagged AND living under a
tagged ancestor is returned once.
Directory expansion only runs when ``mime_type_filter`` is set:
without it, expanding a tagged folder would dump the user's entire
tree into the caller, which is almost never what the operator
wanted.
Args:
tag_name: Name of the system tag to search for (e.g., "vector-index")
mime_type_filter: Optional MIME type filter (e.g., "application/pdf").
When set, also enables directory expansion.
Returns:
List of file dictionaries with WebDAV properties (path, size, content_type, etc.)
Raises:
RuntimeError: If tag lookup or the initial file query fails. A
failure walking one tagged directory is logged and skipped — other
directly-tagged files are still returned.
Examples:
# Find all files with "vector-index" tag (no directory expansion)
files = await nc_client.find_files_by_tag("vector-index")
# Find only PDFs with the tag, including PDFs under any folder
# that carries the tag
pdfs = await nc_client.find_files_by_tag("vector-index", "application/pdf")
"""
tag = await self.webdav.get_tag_by_name(tag_name)
if not tag:
logger.debug("Tag %r not found, returning empty list", tag_name)
return []
items = await self.webdav.get_files_by_tag(tag["id"])
if not items:
logger.debug("No items found with tag %r", tag_name)
return []
logger.debug(
"Found %d directly-tagged item(s) with tag %r", len(items), tag_name
)
# Split into directly-tagged files vs tagged directories.
by_id: dict[int, dict] = {}
tagged_dirs: list[dict] = []
for item in items:
if item.get("is_directory"):
tagged_dirs.append(item)
continue
if mime_type_filter and not item.get("content_type", "").startswith(
mime_type_filter
):
continue
file_id = item.get("id")
if file_id is None:
continue
by_id[file_id] = item
# Expand each tagged directory into its descendant files matching
# the MIME filter. Skip when no MIME filter is set — see docstring.
if mime_type_filter and tagged_dirs:
for dir_info in tagged_dirs:
dir_path = dir_info.get("path", "").strip("/")
try:
descendants = await self.webdav.find_by_type(
mime_type_filter, scope=dir_path
)
except Exception as e:
logger.warning(
"Tag-based directory walk failed for %r (tag %r): %s; "
"skipping descendants",
dir_path,
tag_name,
e,
)
continue
added = 0
for d in descendants:
if d.get("is_directory"):
continue
file_id = d.get("file_id") or d.get("id")
if file_id is None:
continue
if file_id in by_id:
# Directly-tagged entry already wins; keeps the
# canonical shape from get_files_by_tag.
continue
by_id[file_id] = _normalise_search_result(d)
added += 1
logger.debug(
"Tag %r: directory %r expanded to %d descendant %s file(s)",
tag_name,
dir_path,
added,
mime_type_filter,
)
files = list(by_id.values())
if mime_type_filter:
logger.info(
"Returning %d file(s) with tag %r (mime_type=%s, "
"%d directly-tagged folder(s) expanded)",
len(files),
tag_name,
mime_type_filter,
len(tagged_dirs),
)
else:
logger.info("Returning %d file(s) with tag %r", len(files), tag_name)
return files
def _get_webdav_base_path(self) -> str:
"""Helper to get the base WebDAV path for the authenticated user."""
return f"/remote.php/dav/files/{self.username}"
async def __aenter__(self):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit - closes all clients."""
await self.close()
return False # Don't suppress exceptions
async def close(self):
"""Close the HTTP client and CalDAV client."""
await self._client.aclose()
await self.calendar.close()