diff --git a/README.md b/README.md index c0625999..8a99a60b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ For Kubernetes, see [cbcoutinho/helm-charts](https://github.com/cbcoutinho/helm- - **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications - **Flexible Deployment** - Docker, Kubernetes ([Helm chart](https://github.com/cbcoutinho/helm-charts)), VM, or local installation - **Production-Ready Auth** - Basic Auth with app passwords; multi-user via Login Flow v2 — MCP clients authenticate via OAuth, the server handles Nextcloud app passwords transparently +- **Tag-Based File Exclusion** - Hide sensitive files/folders from MCP file tools by tagging them with a configured Nextcloud system tag (`EXCLUDED_TAGS`). See [docs/configuration.md](docs/configuration.md#tag-based-file-exclusion-optional) - **Multiple Transports** - streamable-http (default) and stdio ## Supported Apps diff --git a/docs/configuration.md b/docs/configuration.md index d31f40f8..f6f5d8e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -576,6 +576,87 @@ docker-compose up --- +## Tag-Based File Exclusion (Optional) + +Some files (contracts, medical records, credentials, private notes) should +never be exposed to an LLM, even when the assistant has valid credentials +for the account. The MCP server can hide such files from all WebDAV tools +based on **Nextcloud system tags** (the same collaborative tags users +manage from the Nextcloud UI). + +### Setup + +Set `EXCLUDED_TAGS` to a comma-separated list of system tag names: + +```bash +EXCLUDED_TAGS=confidential,no-ai,private +``` + +Then create the tags in Nextcloud (one-time, as admin): + +```bash +docker compose exec app php occ tag:add 'no-ai' --user-visible=true --user-assignable=false +``` + +`--user-assignable=false` is **strongly recommended** for the threat model +this feature is designed to address — see *Security considerations* below. +Tag any file or folder with one of these tags from the Nextcloud UI to +hide it from the MCP tools. + +Empty (`EXCLUDED_TAGS=""`, the default) disables the feature entirely. + +### Behaviour + +When `EXCLUDED_TAGS` is set, every WebDAV MCP tool resolves the configured +tag names to file paths and applies the following: + +| Tool | Effect on tagged paths | +|------|------------------------| +| `nc_webdav_list_directory` | Excluded files/folders are omitted from listings | +| `nc_webdav_read_file` | Raises `ToolError` (access denied) | +| `nc_webdav_write_file` | Raises `ToolError` (access denied) | +| `nc_webdav_create_directory` | Blocked inside excluded paths | +| `nc_webdav_delete_resource` | Raises `ToolError` (access denied) | +| `nc_webdav_move_resource` | Blocked when source **or** destination is excluded | +| `nc_webdav_copy_resource` | Blocked when source **or** destination is excluded | +| `nc_webdav_search_files` | Excluded files are filtered from results | +| `nc_webdav_find_by_name` | Excluded files are filtered from results | +| `nc_webdav_find_by_type` | Excluded files are filtered from results | +| `nc_webdav_list_favorites` | Excluded files are filtered from results | + +Tagging a **folder** hides the folder itself **and** every descendant +recursively, via path-prefix match. + +### Security considerations + +The threat model is **preventing accidental data exfiltration via the LLM +tool surface**, not hiding files from a determined operator. Specifically: + +- Create exclusion tags with `user_assignable=false` so the credentials + the MCP server uses cannot remove the tag from a file (and thereby + bypass the exclusion). With `user_assignable=true`, any user — including + the one whose credentials the MCP server uses — can untag a file. +- Optionally set `user_visible=false` if the exclusion tag itself is + sensitive metadata. +- The exclusion is enforced at the MCP tool layer only. Direct WebDAV / + Nextcloud client access still sees the files; this feature does not + alter Nextcloud's underlying access control. + +### Performance note + +The excluded path set is resolved per WebDAV tool call (1 PROPFIND for +each tag name + 1 REPORT per tag). For typical setups (a handful of +tagged files under one or two tag names) the overhead is negligible. +Caching may be added in a future release. + +### Scope + +This feature only covers WebDAV file operations. Notes, Calendar, +Contacts, Deck, etc. are not filtered, because they use ID-based APIs +rather than file paths. + +--- + ## Loading Environment Variables After creating your `.env` file, load the environment variables: diff --git a/nextcloud_mcp_server/client/webdav.py b/nextcloud_mcp_server/client/webdav.py index 6c267927..f72baf44 100644 --- a/nextcloud_mcp_server/client/webdav.py +++ b/nextcloud_mcp_server/client/webdav.py @@ -1197,7 +1197,9 @@ class WebDAVClient(BaseNextcloudClient): Returns: List of file info dictionaries with path, size, content_type, etc. """ - # Use WebDAV REPORT method with systemtag filter, requesting all properties + # Use WebDAV REPORT method with systemtag filter. resourcetype is + # included so callers can distinguish folders from files (needed for + # recursive exclusion of tagged directories — see issue #710). report_body = f""" @@ -1207,6 +1209,7 @@ class WebDAVClient(BaseNextcloudClient): + {tag_id} @@ -1249,10 +1252,17 @@ class WebDAVClient(BaseNextcloudClient): contenttype_elem = prop.find("d:getcontenttype", ns) lastmodified_elem = prop.find("d:getlastmodified", ns) etag_elem = prop.find("d:getetag", ns) + resourcetype_elem = prop.find("d:resourcetype", ns) if fileid_elem is None or not fileid_elem.text: continue + # A resourcetype with a child indicates a folder. + is_directory = ( + resourcetype_elem is not None + and resourcetype_elem.find("d:collection", ns) is not None + ) + # Decode href path and extract the file path href_path = unquote(href_elem.text) # Remove WebDAV prefix to get user-relative path @@ -1285,10 +1295,11 @@ class WebDAVClient(BaseNextcloudClient): else None, "last_modified_timestamp": last_modified_timestamp, "etag": etag_elem.text if etag_elem is not None else None, + "is_directory": is_directory, } files.append(file_info) - logger.debug(f"Found {len(files)} files with tag ID {tag_id}") + logger.debug("Found %d files with tag ID %s", len(files), tag_id) return files async def get_file_info(self, path: str) -> dict[str, Any] | None: diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index eb2cb59f..60a0a714 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -117,6 +117,10 @@ _DEFAULTS: dict[str, Any] = { "custom_processor_name": "custom", "custom_processor_api_key": None, "custom_processor_timeout": 60, + # Tag-based file exclusion (issue #710): comma-separated list of + # Nextcloud system tag names. Files/folders carrying any of these tags + # are hidden from WebDAV MCP tools. Empty = feature off. + "excluded_tags": "", } @@ -508,6 +512,11 @@ class Settings: log_level: str = "INFO" log_include_trace_context: bool = True + # Tag-based file exclusion (issue #710): comma-separated list of + # Nextcloud system tag names. Files/folders carrying any of these tags + # are hidden from WebDAV MCP tools. + excluded_tags: str = "" + def __post_init__(self): """Validate configuration and set defaults.""" logger = logging.getLogger(__name__) @@ -845,6 +854,7 @@ def get_settings() -> Settings: "log_format": "LOG_FORMAT", "log_level": "LOG_LEVEL", "log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT", + "excluded_tags": "EXCLUDED_TAGS", } # Only pass values that dynaconf actually has; omit unset keys so diff --git a/nextcloud_mcp_server/server/tag_exclusion.py b/nextcloud_mcp_server/server/tag_exclusion.py new file mode 100644 index 00000000..6f5252b5 --- /dev/null +++ b/nextcloud_mcp_server/server/tag_exclusion.py @@ -0,0 +1,98 @@ +"""Tag-based file exclusion for MCP file operations (issue #710). + +Resolves the configured ``EXCLUDED_TAGS`` to a set of file paths that +should be hidden from WebDAV MCP tools (list, read, search) and rejected +by mutating tools (write, delete, move, copy). + +The flow per call: + +1. Parse ``EXCLUDED_TAGS`` (comma-separated tag names) from config. +2. For each tag name, resolve to a tag ID via ``get_tag_by_name``. +3. For each tag ID, fetch all tagged file/folder paths via + ``get_files_by_tag``. +4. Collect normalised paths into a single ``set[str]``. + +Tagging a *folder* excludes the folder itself and every descendant via +prefix match in :func:`is_path_excluded`. + +Threat model: this is a defence-in-depth control to prevent accidental +exfiltration via the LLM tool surface. A user controlling the Nextcloud +account whose credentials the server uses can untag files unless the tag +is created with ``user_assignable=false``. +""" + +import logging + +from nextcloud_mcp_server.client.webdav import WebDAVClient +from nextcloud_mcp_server.config import get_settings + +logger = logging.getLogger(__name__) + + +def get_excluded_tag_names() -> list[str]: + """Return the configured excluded tag names (empty list if disabled).""" + raw = get_settings().excluded_tags + if not raw: + return [] + return [t.strip() for t in raw.split(",") if t.strip()] + + +async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]: + """Resolve excluded tags to the set of paths they cover. + + Tagged directories are added as their own normalised path; descendants + are blocked via prefix match in :func:`is_path_excluded`. + """ + tag_names = get_excluded_tag_names() + if not tag_names: + return set() + + excluded: set[str] = set() + for tag_name in tag_names: + tag = await webdav.get_tag_by_name(tag_name) + if tag is None: + logger.debug("Excluded tag %r does not exist — skipping", tag_name) + continue + + files = await webdav.get_files_by_tag(tag["id"]) + for f in files: + path = _normalise_path(f["path"]) + excluded.add(path) + if f.get("is_directory"): + logger.debug( + "Excluding directory %r (tag %r) — descendants will be hidden", + path, + tag_name, + ) + + if excluded: + logger.info( + "Tag-based exclusion: hiding %d path(s) matching tags: %s", + len(excluded), + ", ".join(tag_names), + ) + + return excluded + + +def is_path_excluded(path: str, excluded_paths: set[str]) -> bool: + """Return True if *path* (or any of its parents) is excluded. + + A path is excluded when it matches an entry exactly, or when an + excluded entry is one of its directory ancestors (prefix match on + ``/``). + """ + if not excluded_paths: + return False + normalised = _normalise_path(path) + if normalised in excluded_paths: + return True + for exc in excluded_paths: + if normalised.startswith(exc + "/"): + return True + return False + + +def _normalise_path(path: str) -> str: + """Strip leading/trailing slashes for consistent comparison.""" + return path.strip("/") diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index c89f3896..c5f3ef1c 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -2,12 +2,17 @@ import base64 import logging from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.exceptions import ToolError from mcp.types import ToolAnnotations from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse from nextcloud_mcp_server.observability.metrics import instrument_tool +from nextcloud_mcp_server.server.tag_exclusion import ( + get_excluded_file_paths, + is_path_excluded, +) from nextcloud_mcp_server.utils.document_parser import ( is_parseable_document, parse_document, @@ -32,6 +37,10 @@ def configure_webdav_tools(mcp: FastMCP): ) -> DirectoryListing: """List files and directories in the specified NextCloud path. + When ``EXCLUDED_TAGS`` is configured, entries tagged (or whose + ancestor folders are tagged) with an excluded system tag are + omitted from the result. + Args: path: Directory path to list (empty string for root directory) @@ -41,6 +50,13 @@ def configure_webdav_tools(mcp: FastMCP): client = await get_client(ctx) items = await client.webdav.list_directory(path) + # Filter out files/folders carrying an excluded tag. + excluded = await get_excluded_file_paths(client.webdav) + if excluded: + items = [ + i for i in items if not is_path_excluded(i.get("path", ""), excluded) + ] + # Convert to FileInfo models file_infos = [FileInfo(**item) for item in items] @@ -70,6 +86,9 @@ def configure_webdav_tools(mcp: FastMCP): async def nc_webdav_read_file(path: str, ctx: Context): """Read the content of a file from NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the + file (or an ancestor folder) carries an excluded system tag. + Args: path: Full path to the file to read @@ -80,13 +99,19 @@ def configure_webdav_tools(mcp: FastMCP): - Other binary files are base64 encoded """ client = await get_client(ctx) + + # Block reads of paths carrying an excluded tag. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(path, excluded): + raise ToolError(f"Access denied: {path!r} is tagged with an excluded tag") + content, content_type = await client.webdav.read_file(path) # Check if this is a parseable document (PDF, DOCX, etc.) # is_parseable_document() checks if document processing is enabled if is_parseable_document(content_type): try: - logger.info(f"Parsing document '{path}' of type '{content_type}'") + logger.info("Parsing document %r of type %r", path, content_type) parsed_text, metadata = await parse_document( content, content_type, @@ -103,7 +128,9 @@ def configure_webdav_tools(mcp: FastMCP): } except Exception as e: logger.warning( - f"Failed to parse document '{path}', falling back to base64: {e}" + "Failed to parse document %r, falling back to base64: %s", + path, + e, ) # Fall through to base64 encoding on parse failure @@ -144,6 +171,9 @@ def configure_webdav_tools(mcp: FastMCP): ): """Write content to a file in NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the + target path (or an ancestor folder) carries an excluded system tag. + Args: path: Full path where to write the file content: File content (text or base64 for binary) @@ -154,6 +184,11 @@ def configure_webdav_tools(mcp: FastMCP): """ client = await get_client(ctx) + # Block writes to excluded paths. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(path, excluded): + raise ToolError(f"Access denied: {path!r} is tagged with an excluded tag") + # Handle base64 encoded content if content_type and "base64" in content_type.lower(): content_bytes = base64.b64decode(content) @@ -175,6 +210,9 @@ def configure_webdav_tools(mcp: FastMCP): async def nc_webdav_create_directory(path: str, ctx: Context): """Create a directory in NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the + target path lies inside a folder carrying an excluded system tag. + Args: path: Full path of the directory to create @@ -182,6 +220,14 @@ def configure_webdav_tools(mcp: FastMCP): Dict with status_code (201 for created, 405 if already exists) """ client = await get_client(ctx) + + # Block directory creation inside excluded paths. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(path, excluded): + raise ToolError( + f"Access denied: {path!r} is inside a path tagged with an excluded tag" + ) + return await client.webdav.create_directory(path) @mcp.tool( @@ -197,6 +243,9 @@ def configure_webdav_tools(mcp: FastMCP): async def nc_webdav_delete_resource(path: str, ctx: Context): """Delete a file or directory in NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the + target path (or an ancestor folder) carries an excluded system tag. + Args: path: Full path of the file or directory to delete @@ -204,6 +253,12 @@ def configure_webdav_tools(mcp: FastMCP): Dict with status_code indicating result (404 if not found) """ client = await get_client(ctx) + + # Block deletion of excluded files/directories. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(path, excluded): + raise ToolError(f"Access denied: {path!r} is tagged with an excluded tag") + return await client.webdav.delete_resource(path) @mcp.tool( @@ -220,6 +275,10 @@ def configure_webdav_tools(mcp: FastMCP): ): """Move or rename a file or directory in NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and either + the source or destination path (or one of their ancestor folders) + carries an excluded system tag. + Args: source_path: Full path of the file or directory to move destination_path: New path for the file or directory @@ -229,6 +288,19 @@ def configure_webdav_tools(mcp: FastMCP): Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) """ client = await get_client(ctx) + + # Block moves involving excluded paths on either side. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(source_path, excluded): + raise ToolError( + f"Access denied: source {source_path!r} is tagged with an excluded tag" + ) + if is_path_excluded(destination_path, excluded): + raise ToolError( + f"Access denied: destination {destination_path!r} is inside a " + "path tagged with an excluded tag" + ) + return await client.webdav.move_resource( source_path, destination_path, overwrite ) @@ -247,6 +319,10 @@ def configure_webdav_tools(mcp: FastMCP): ): """Copy a file or directory in NextCloud. + Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and either + the source or destination path (or one of their ancestor folders) + carries an excluded system tag. + Args: source_path: Full path of the file or directory to copy destination_path: Destination path for the copy @@ -256,6 +332,19 @@ def configure_webdav_tools(mcp: FastMCP): Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) """ client = await get_client(ctx) + + # Block copies involving excluded paths on either side. + excluded = await get_excluded_file_paths(client.webdav) + if is_path_excluded(source_path, excluded): + raise ToolError( + f"Access denied: source {source_path!r} is tagged with an excluded tag" + ) + if is_path_excluded(destination_path, excluded): + raise ToolError( + f"Access denied: destination {destination_path!r} is inside a " + "path tagged with an excluded tag" + ) + return await client.webdav.copy_resource( source_path, destination_path, overwrite ) @@ -364,6 +453,13 @@ def configure_webdav_tools(mcp: FastMCP): limit=limit, ) + # Filter out tagged-excluded paths. + excluded = await get_excluded_file_paths(client.webdav) + if excluded: + results = [ + r for r in results if not is_path_excluded(r.get("path", ""), excluded) + ] + # Convert to FileInfo models file_infos = [FileInfo(**result) for result in results] @@ -409,6 +505,11 @@ def configure_webdav_tools(mcp: FastMCP): results = await client.webdav.find_by_name( pattern=pattern, scope=scope, limit=limit ) + excluded = await get_excluded_file_paths(client.webdav) + if excluded: + results = [ + r for r in results if not is_path_excluded(r.get("path", ""), excluded) + ] file_infos = [FileInfo(**result) for result in results] return SearchFilesResponse( results=file_infos, @@ -443,6 +544,11 @@ def configure_webdav_tools(mcp: FastMCP): results = await client.webdav.find_by_type( mime_type=mime_type, scope=scope, limit=limit ) + excluded = await get_excluded_file_paths(client.webdav) + if excluded: + results = [ + r for r in results if not is_path_excluded(r.get("path", ""), excluded) + ] file_infos = [FileInfo(**result) for result in results] return SearchFilesResponse( results=file_infos, @@ -474,6 +580,11 @@ def configure_webdav_tools(mcp: FastMCP): """ client = await get_client(ctx) results = await client.webdav.list_favorites(scope=scope, limit=limit) + excluded = await get_excluded_file_paths(client.webdav) + if excluded: + results = [ + r for r in results if not is_path_excluded(r.get("path", ""), excluded) + ] file_infos = [FileInfo(**result) for result in results] return SearchFilesResponse( results=file_infos, diff --git a/tests/unit/client/test_webdav.py b/tests/unit/client/test_webdav.py index 0218d336..03b0b728 100644 --- a/tests/unit/client/test_webdav.py +++ b/tests/unit/client/test_webdav.py @@ -354,3 +354,63 @@ async def test_remove_tag_from_file_not_assigned(mocker): # Verify result (should succeed even with 404) assert result is True + + +@pytest.mark.unit +async def test_get_files_by_tag_detects_directories(mocker): + """get_files_by_tag must flag tagged folders via . + + Tagged folders need ``is_directory=True`` so the tag-exclusion layer + (issue #710) can hide their descendants. + """ + mock_http_client = AsyncMock() + client = WebDAVClient(mock_http_client, "testuser") + + # Two-entry response: one regular file, one collection (folder). + xml_content = b""" + + + /remote.php/dav/files/testuser/Secret.txt + + + 101 + Secret.txt + 42 + text/plain + Wed, 01 Jan 2025 00:00:00 GMT + "abc" + + + + + + /remote.php/dav/files/testuser/Private/ + + + 102 + Private + Wed, 01 Jan 2025 00:00:00 GMT + "def" + + + + + """ + + mock_response = AsyncMock() + mock_response.content = xml_content + mock_response.raise_for_status = mocker.Mock() + mock_http_client.request = AsyncMock(return_value=mock_response) + + files = await client.get_files_by_tag(42) + + assert len(files) == 2 + by_path = {f["path"]: f for f in files} + + assert by_path["/Secret.txt"]["is_directory"] is False + assert by_path["/Private/"]["is_directory"] is True + + # Sanity-check the REPORT body asks for resourcetype. + call_args = mock_http_client.request.call_args + assert "" in call_args.kwargs["content"] + assert "42" in call_args.kwargs["content"] diff --git a/tests/unit/test_tag_exclusion.py b/tests/unit/test_tag_exclusion.py new file mode 100644 index 00000000..1e31e96b --- /dev/null +++ b/tests/unit/test_tag_exclusion.py @@ -0,0 +1,152 @@ +"""Unit tests for tag-based file exclusion (issue #710).""" + +import os +from unittest.mock import AsyncMock, patch + +import pytest + +from nextcloud_mcp_server.config import _reload_config +from nextcloud_mcp_server.server.tag_exclusion import ( + _normalise_path, + get_excluded_file_paths, + get_excluded_tag_names, + is_path_excluded, +) + + +class TestNormalisePath: + @pytest.mark.unit + def test_strips_leading_and_trailing_slash(self): + assert _normalise_path("/foo/bar/") == "foo/bar" + + @pytest.mark.unit + def test_unchanged_when_already_clean(self): + assert _normalise_path("foo/bar") == "foo/bar" + + @pytest.mark.unit + def test_empty_string(self): + assert _normalise_path("") == "" + + +class TestIsPathExcluded: + @pytest.mark.unit + def test_empty_set_excludes_nothing(self): + assert is_path_excluded("/anything", set()) is False + + @pytest.mark.unit + def test_direct_match(self): + assert is_path_excluded("/Secret.txt", {"Secret.txt"}) is True + + @pytest.mark.unit + def test_path_argument_is_normalised(self): + # The path argument is normalised before comparison; the excluded + # set is expected to already contain normalised entries (it always + # is, in practice, because get_excluded_file_paths builds it). + assert is_path_excluded("/Secret.txt/", {"Secret.txt"}) is True + + @pytest.mark.unit + def test_descendant_of_excluded_directory(self): + assert is_path_excluded("/Private/notes.md", {"Private"}) is True + assert is_path_excluded("/Private/sub/file.txt", {"Private"}) is True + + @pytest.mark.unit + def test_unrelated_path_not_excluded(self): + assert is_path_excluded("/Public/notes.md", {"Private"}) is False + + @pytest.mark.unit + def test_shared_prefix_is_not_a_match(self): + # 'foobar' must NOT be excluded just because 'foo' is. + # This is the bug a naive `startswith(exc)` would have. + assert is_path_excluded("/foobar/x", {"foo"}) is False + assert is_path_excluded("/foobar", {"foo"}) is False + + @pytest.mark.unit + def test_excluded_path_itself(self): + # The excluded entry itself is excluded (not just its descendants). + assert is_path_excluded("/Private", {"Private"}) is True + + +class TestGetExcludedTagNames: + @pytest.mark.unit + @patch.dict(os.environ, {"EXCLUDED_TAGS": ""}, clear=False) + def test_empty_returns_empty_list(self): + _reload_config() + assert get_excluded_tag_names() == [] + + @pytest.mark.unit + @patch.dict(os.environ, {"EXCLUDED_TAGS": "secret"}, clear=False) + def test_single_tag(self): + _reload_config() + assert get_excluded_tag_names() == ["secret"] + + @pytest.mark.unit + @patch.dict(os.environ, {"EXCLUDED_TAGS": " a , b , c "}, clear=False) + def test_strips_whitespace_around_each_tag(self): + _reload_config() + assert get_excluded_tag_names() == ["a", "b", "c"] + + @pytest.mark.unit + @patch.dict(os.environ, {"EXCLUDED_TAGS": "a,,b,"}, clear=False) + def test_skips_empty_entries(self): + _reload_config() + assert get_excluded_tag_names() == ["a", "b"] + + +class TestGetExcludedFilePaths: + @pytest.mark.unit + async def test_returns_empty_set_when_feature_disabled(self, mocker): + mocker.patch( + "nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names", + return_value=[], + ) + webdav = AsyncMock() + result = await get_excluded_file_paths(webdav) + assert result == set() + webdav.get_tag_by_name.assert_not_called() + + @pytest.mark.unit + async def test_skips_unknown_tag(self, mocker): + mocker.patch( + "nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names", + return_value=["does-not-exist"], + ) + webdav = AsyncMock() + webdav.get_tag_by_name = AsyncMock(return_value=None) + + result = await get_excluded_file_paths(webdav) + + assert result == set() + webdav.get_tag_by_name.assert_awaited_once_with("does-not-exist") + webdav.get_files_by_tag.assert_not_called() + + @pytest.mark.unit + async def test_collects_paths_from_multiple_tags(self, mocker): + mocker.patch( + "nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names", + return_value=["secret", "no-ai"], + ) + webdav = AsyncMock() + webdav.get_tag_by_name = AsyncMock( + side_effect=[ + {"id": 1, "name": "secret"}, + {"id": 2, "name": "no-ai"}, + ] + ) + webdav.get_files_by_tag = AsyncMock( + side_effect=[ + [ + {"path": "/Secret.txt", "is_directory": False}, + {"path": "/Private/", "is_directory": True}, + ], + [ + # Same dir under a second tag — set dedupes it. + {"path": "/Private", "is_directory": True}, + {"path": "/Other/notes.md", "is_directory": False}, + ], + ] + ) + + result = await get_excluded_file_paths(webdav) + + assert result == {"Secret.txt", "Private", "Other/notes.md"} + assert webdav.get_files_by_tag.await_count == 2