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:
co-authored by
Claude Opus 4.7
parent
b23f7d9534
commit
22ed9e99a0
@@ -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
|
- **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
|
- **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
|
- **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
|
- **Multiple Transports** - streamable-http (default) and stdio
|
||||||
|
|
||||||
## Supported Apps
|
## Supported Apps
|
||||||
|
|||||||
@@ -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
|
## Loading Environment Variables
|
||||||
|
|
||||||
After creating your `.env` file, load the environment variables:
|
After creating your `.env` file, load the environment variables:
|
||||||
|
|||||||
@@ -1197,7 +1197,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of file info dictionaries with path, size, content_type, etc.
|
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"?>
|
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">
|
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
<d:prop>
|
<d:prop>
|
||||||
@@ -1207,6 +1209,7 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
<d:getcontenttype/>
|
<d:getcontenttype/>
|
||||||
<d:getlastmodified/>
|
<d:getlastmodified/>
|
||||||
<d:getetag/>
|
<d:getetag/>
|
||||||
|
<d:resourcetype/>
|
||||||
</d:prop>
|
</d:prop>
|
||||||
<oc:filter-rules>
|
<oc:filter-rules>
|
||||||
<oc:systemtag>{tag_id}</oc:systemtag>
|
<oc:systemtag>{tag_id}</oc:systemtag>
|
||||||
@@ -1249,10 +1252,17 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||||
etag_elem = prop.find("d:getetag", 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:
|
if fileid_elem is None or not fileid_elem.text:
|
||||||
continue
|
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
|
# Decode href path and extract the file path
|
||||||
href_path = unquote(href_elem.text)
|
href_path = unquote(href_elem.text)
|
||||||
# Remove WebDAV prefix to get user-relative path
|
# Remove WebDAV prefix to get user-relative path
|
||||||
@@ -1285,10 +1295,11 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
else None,
|
else None,
|
||||||
"last_modified_timestamp": last_modified_timestamp,
|
"last_modified_timestamp": last_modified_timestamp,
|
||||||
"etag": etag_elem.text if etag_elem is not None else None,
|
"etag": etag_elem.text if etag_elem is not None else None,
|
||||||
|
"is_directory": is_directory,
|
||||||
}
|
}
|
||||||
files.append(file_info)
|
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
|
return files
|
||||||
|
|
||||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ _DEFAULTS: dict[str, Any] = {
|
|||||||
"custom_processor_name": "custom",
|
"custom_processor_name": "custom",
|
||||||
"custom_processor_api_key": None,
|
"custom_processor_api_key": None,
|
||||||
"custom_processor_timeout": 60,
|
"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_level: str = "INFO"
|
||||||
log_include_trace_context: bool = True
|
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):
|
def __post_init__(self):
|
||||||
"""Validate configuration and set defaults."""
|
"""Validate configuration and set defaults."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -845,6 +854,7 @@ def get_settings() -> Settings:
|
|||||||
"log_format": "LOG_FORMAT",
|
"log_format": "LOG_FORMAT",
|
||||||
"log_level": "LOG_LEVEL",
|
"log_level": "LOG_LEVEL",
|
||||||
"log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT",
|
"log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT",
|
||||||
|
"excluded_tags": "EXCLUDED_TAGS",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only pass values that dynaconf actually has; omit unset keys so
|
# 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("/")
|
||||||
@@ -2,12 +2,17 @@ import base64
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.server.fastmcp.exceptions import ToolError
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
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 (
|
from nextcloud_mcp_server.utils.document_parser import (
|
||||||
is_parseable_document,
|
is_parseable_document,
|
||||||
parse_document,
|
parse_document,
|
||||||
@@ -32,6 +37,10 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
) -> DirectoryListing:
|
) -> DirectoryListing:
|
||||||
"""List files and directories in the specified NextCloud path.
|
"""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:
|
Args:
|
||||||
path: Directory path to list (empty string for root directory)
|
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)
|
client = await get_client(ctx)
|
||||||
items = await client.webdav.list_directory(path)
|
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
|
# Convert to FileInfo models
|
||||||
file_infos = [FileInfo(**item) for item in items]
|
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):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
"""Read the content of a file from NextCloud.
|
"""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:
|
Args:
|
||||||
path: Full path to the file to read
|
path: Full path to the file to read
|
||||||
|
|
||||||
@@ -80,13 +99,19 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
- Other binary files are base64 encoded
|
- Other binary files are base64 encoded
|
||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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)
|
content, content_type = await client.webdav.read_file(path)
|
||||||
|
|
||||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||||
# is_parseable_document() checks if document processing is enabled
|
# is_parseable_document() checks if document processing is enabled
|
||||||
if is_parseable_document(content_type):
|
if is_parseable_document(content_type):
|
||||||
try:
|
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(
|
parsed_text, metadata = await parse_document(
|
||||||
content,
|
content,
|
||||||
content_type,
|
content_type,
|
||||||
@@ -103,7 +128,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
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
|
# 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.
|
"""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:
|
Args:
|
||||||
path: Full path where to write the file
|
path: Full path where to write the file
|
||||||
content: File content (text or base64 for binary)
|
content: File content (text or base64 for binary)
|
||||||
@@ -154,6 +184,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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
|
# Handle base64 encoded content
|
||||||
if content_type and "base64" in content_type.lower():
|
if content_type and "base64" in content_type.lower():
|
||||||
content_bytes = base64.b64decode(content)
|
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):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
"""Create a directory in NextCloud.
|
"""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:
|
Args:
|
||||||
path: Full path of the directory to create
|
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)
|
Dict with status_code (201 for created, 405 if already exists)
|
||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -197,6 +243,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
"""Delete a file or directory in NextCloud.
|
"""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:
|
Args:
|
||||||
path: Full path of the file or directory to delete
|
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)
|
Dict with status_code indicating result (404 if not found)
|
||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -220,6 +275,10 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
):
|
):
|
||||||
"""Move or rename a file or directory in NextCloud.
|
"""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:
|
Args:
|
||||||
source_path: Full path of the file or directory to move
|
source_path: Full path of the file or directory to move
|
||||||
destination_path: New path for the file or directory
|
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)
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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(
|
return await client.webdav.move_resource(
|
||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
@@ -247,6 +319,10 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
):
|
):
|
||||||
"""Copy a file or directory in NextCloud.
|
"""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:
|
Args:
|
||||||
source_path: Full path of the file or directory to copy
|
source_path: Full path of the file or directory to copy
|
||||||
destination_path: Destination path for the 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)
|
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
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(
|
return await client.webdav.copy_resource(
|
||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
@@ -364,6 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
limit=limit,
|
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
|
# Convert to FileInfo models
|
||||||
file_infos = [FileInfo(**result) for result in results]
|
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(
|
results = await client.webdav.find_by_name(
|
||||||
pattern=pattern, scope=scope, limit=limit
|
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]
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
return SearchFilesResponse(
|
return SearchFilesResponse(
|
||||||
results=file_infos,
|
results=file_infos,
|
||||||
@@ -443,6 +544,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
results = await client.webdav.find_by_type(
|
results = await client.webdav.find_by_type(
|
||||||
mime_type=mime_type, scope=scope, limit=limit
|
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]
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
return SearchFilesResponse(
|
return SearchFilesResponse(
|
||||||
results=file_infos,
|
results=file_infos,
|
||||||
@@ -474,6 +580,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
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]
|
file_infos = [FileInfo(**result) for result in results]
|
||||||
return SearchFilesResponse(
|
return SearchFilesResponse(
|
||||||
results=file_infos,
|
results=file_infos,
|
||||||
|
|||||||
@@ -354,3 +354,63 @@ async def test_remove_tag_from_file_not_assigned(mocker):
|
|||||||
|
|
||||||
# Verify result (should succeed even with 404)
|
# Verify result (should succeed even with 404)
|
||||||
assert result is True
|
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"]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user