feat(webdav): add tag-based file exclusion (#710)

Hide sensitive files/folders from the WebDAV MCP tool surface by
tagging them with a configured Nextcloud system tag. Defence-in-depth
control for users who connect LLMs to accounts holding contracts,
medical records, credentials, etc.

A new EXCLUDED_TAGS env var (comma-separated tag names, empty by
default) gates an exclusion layer that runs at the start of every
WebDAV tool call: tag names are resolved to tag IDs, those IDs are
expanded to the set of tagged paths, then listings/searches are
filtered and read/write/delete/move/copy operations on excluded paths
raise ToolError. Tagged folders exclude their descendants via prefix
match. Empty EXCLUDED_TAGS disables the feature entirely.

The threat model is preventing accidental data exfiltration via the
LLM tool surface — not hiding files from a determined operator. The
docs explicitly recommend creating exclusion tags with
user_assignable=false so the credentials the MCP server uses cannot
remove the tag.

Implementation:

- config.py: add `excluded_tags` to _DEFAULTS, Settings, and the
  _field_map alongside other comma-separated env vars.
- client/webdav.py: get_files_by_tag now requests <d:resourcetype/>
  and surfaces is_directory so tagged directories can recursively
  exclude descendants.
- server/tag_exclusion.py (new): get_excluded_tag_names,
  get_excluded_file_paths, is_path_excluded.
- server/webdav.py: exclusion guards in all 11 WebDAV tools;
  read/write/create/delete/move/copy raise ToolError, list/search
  tools silently filter excluded entries. Existing f-string log
  calls converted to lazy %-style.
- tests: 17 new unit tests covering path-matching edge cases
  (shared-prefix non-match, descendants of excluded dirs), tag-name
  parsing, and get_excluded_file_paths with mocked WebDAV; 1 new
  client test asserting <d:resourcetype/> -> is_directory parsing.
- docs/configuration.md: new "Tag-Based File Exclusion" section with
  per-tool effect table, security guidance, and per-call cost note.
- README.md: feature mention under Key Features.

Closes #710.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-06 12:12:54 +02:00
co-authored by Claude Opus 4.7
parent b23f7d9534
commit 22ed9e99a0
8 changed files with 528 additions and 4 deletions
+1
View File
@@ -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
+81
View File
@@ -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:
+13 -2
View File
@@ -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"""<?xml version="1.0"?>
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
@@ -1207,6 +1209,7 @@ class WebDAVClient(BaseNextcloudClient):
<d:getcontenttype/>
<d:getlastmodified/>
<d:getetag/>
<d:resourcetype/>
</d:prop>
<oc:filter-rules>
<oc:systemtag>{tag_id}</oc:systemtag>
@@ -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 <d:collection/> 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:
+10
View File
@@ -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
@@ -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
``<dir>/``).
"""
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("/")
+113 -2
View File
@@ -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,
+60
View File
@@ -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 <d:resourcetype/>.
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"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Secret.txt</d:href>
<d:propstat>
<d:prop>
<oc:fileid>101</oc:fileid>
<d:displayname>Secret.txt</d:displayname>
<d:getcontentlength>42</d:getcontentlength>
<d:getcontenttype>text/plain</d:getcontenttype>
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"abc"</d:getetag>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
<d:response>
<d:href>/remote.php/dav/files/testuser/Private/</d:href>
<d:propstat>
<d:prop>
<oc:fileid>102</oc:fileid>
<d:displayname>Private</d:displayname>
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"def"</d:getetag>
<d:resourcetype><d:collection/></d:resourcetype>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
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 "<d:resourcetype/>" in call_args.kwargs["content"]
assert "<oc:systemtag>42</oc:systemtag>" in call_args.kwargs["content"]
+152
View File
@@ -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