Compare commits
22 Commits
v0.4.1
...
fix/webdav
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ad07d05dd | |||
| 50c1215676 | |||
| bf5879d408 | |||
| 9e96999f02 | |||
| e983693534 | |||
| b8a14a2229 | |||
| 508f83dfad | |||
| ce8d5f92b1 | |||
| ca32ff39b8 | |||
| 9da53e51f0 | |||
| 2cbac7c4be | |||
| d2394465d7 | |||
| c2615ac24d | |||
| 62e21f1f94 | |||
| bfd2eed97b | |||
| 8a0b964add | |||
| 59bab51090 | |||
| 12fa550b60 | |||
| 85cdf75a5b | |||
| 0ee2b5b034 | |||
| 0c4d140bb9 | |||
| f515d74a4d |
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6
|
||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Feat
|
||||
|
||||
- **webdav**: Add complete file system support with directory browsing, file read/write, and resource management
|
||||
- **webdav**: Add `nc_webdav_list_directory` tool for browsing any NextCloud directory
|
||||
- **webdav**: Add `nc_webdav_read_file` tool with automatic text/binary content handling
|
||||
- **webdav**: Add `nc_webdav_write_file` tool supporting text and base64 binary content
|
||||
- **webdav**: Add `nc_webdav_create_directory` tool for creating directories
|
||||
- **webdav**: Add `nc_webdav_delete_resource` tool for deleting files and directories
|
||||
- **webdav**: Add XML parsing for WebDAV PROPFIND responses with metadata extraction
|
||||
|
||||
### Fix
|
||||
|
||||
- **types**: Improve type annotations throughout codebase for better IDE support
|
||||
- **types**: Fix Context parameter ordering in MCP tools (required before optional)
|
||||
- **types**: Add proper type hints for WebDAV client methods
|
||||
|
||||
### Refactor
|
||||
|
||||
- **webdav**: Extend WebDAV client beyond Notes attachments to general file operations
|
||||
- **server**: Enhance error handling and logging for WebDAV operations
|
||||
|
||||
## v0.4.1 (2025-07-10)
|
||||
|
||||
### Fix
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.7.20-python3.11-alpine@sha256:5676209b5e686d60bfae32d9f656bf935a5613759860c447ed90a37a89192caa
|
||||
FROM ghcr.io/astral-sh/uv:0.8.3-python3.11-alpine@sha256:886c19178558b951bbb9cb242deb94e7e37f9cba5d0dc018cd210ccd6b5116db
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
|-----|----------------|-------------|
|
||||
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
|
||||
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
|
||||
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -39,6 +40,16 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| `nc_tables_update_row` | Update an existing row in a table |
|
||||
| `nc_tables_delete_row` | Delete a row from a table |
|
||||
|
||||
### WebDAV File System Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
|
||||
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
|
||||
| `nc_webdav_write_file` | Create or update files in NextCloud |
|
||||
| `nc_webdav_create_directory` | Create new directories |
|
||||
| `nc_webdav_delete_resource` | Delete files or directories |
|
||||
|
||||
## Available Resources
|
||||
|
||||
| Resource | Description |
|
||||
@@ -47,6 +58,37 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
|
||||
| `notes://settings` | Access Notes app settings |
|
||||
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
|
||||
|
||||
### WebDAV File System Access
|
||||
|
||||
The server provides complete file system access to your NextCloud instance, enabling you to:
|
||||
|
||||
- Browse any directory structure
|
||||
- Read and write files of any type
|
||||
- Create and delete directories
|
||||
- Manage your NextCloud files directly through LLM interactions
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
```python
|
||||
# List files in root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# Browse a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
|
||||
# Read a text file
|
||||
content = await nc_webdav_read_file("Documents/readme.txt")
|
||||
|
||||
# Create a new directory
|
||||
await nc_webdav_create_directory("NewProject/docs")
|
||||
|
||||
# Write content to a file
|
||||
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
|
||||
|
||||
# Delete a file or directory
|
||||
await nc_webdav_delete_resource("old_file.txt")
|
||||
```
|
||||
|
||||
### Note Attachments
|
||||
|
||||
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: mariadb:lts@sha256:1e4ec03d1b73af8e7a63137b8ef4820ac7d54c654a1e99eb76235f210f7f0a06
|
||||
image: mariadb:lts@sha256:2bcbaec92bd9d4f6591bc8103d3a8e6d0512ee2235506e47a2e129d190444405
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:73734b014e53b3067916918b70718ca188c16895511a272a020c9a71084eecda
|
||||
image: redis:alpine@sha256:d12963afb039f10c1fa933187e0d60a128b4d355bc4575d6c143674b38b28019
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.6@sha256:588609d76b217cfd0feda653eea9894eeb12e612b327d2f1dcd38221ad242be0
|
||||
image: nextcloud:31.0.7@sha256:31d564f5f9f43f2aed0633854a2abd39155f85aa156997f7252f5af908efa99b
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""WebDAV client for Nextcloud file operations."""
|
||||
|
||||
import mimetypes
|
||||
from typing import Tuple, Dict, Any, Optional
|
||||
from typing import Tuple, Dict, Any, Optional, List
|
||||
import logging
|
||||
from httpx import HTTPStatusError
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
@@ -22,7 +23,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
path_with_slash = path
|
||||
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
|
||||
logger.info(f"Deleting WebDAV resource: {webdav_path}")
|
||||
logger.debug(f"Deleting WebDAV resource: {webdav_path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
try:
|
||||
@@ -32,36 +33,30 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
propfind_resp = await self._client.request(
|
||||
"PROPFIND", webdav_path, headers=propfind_headers
|
||||
)
|
||||
logger.info(
|
||||
f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}"
|
||||
logger.debug(
|
||||
f"Resource exists check status: {propfind_resp.status_code}"
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.info(
|
||||
f"Resource '{webdav_path}' doesn't exist, no deletion needed."
|
||||
)
|
||||
logger.debug(f"Resource '{path}' doesn't exist, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
# For other errors, continue with deletion attempt
|
||||
|
||||
# Proceed with deletion
|
||||
response = await self._client.delete(webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully deleted WebDAV resource '{webdav_path}' (Status: {response.status_code})"
|
||||
)
|
||||
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.warning(f"HTTP error deleting WebDAV resource '{webdav_path}': {e}")
|
||||
if e.response.status_code != 404:
|
||||
raise e
|
||||
else:
|
||||
logger.info(f"Resource '{webdav_path}' not found, no deletion needed.")
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Resource '{path}' not found, no deletion needed")
|
||||
return {"status_code": 404}
|
||||
else:
|
||||
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Unexpected error deleting WebDAV resource '{webdav_path}': {e}"
|
||||
)
|
||||
logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def cleanup_old_attachment_directory(
|
||||
@@ -73,10 +68,10 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Notes/{old_category_path_part}.attachments.{note_id}/"
|
||||
)
|
||||
|
||||
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=old_attachment_dir_path)
|
||||
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
|
||||
logger.debug(f"Cleanup result: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup of old attachment directory: {e}")
|
||||
@@ -89,19 +84,15 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
cat_path_part = f"{category}/" if category else ""
|
||||
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
|
||||
|
||||
logger.info(
|
||||
f"Attempting to delete attachment directory for note {note_id} in category '{category}' via WebDAV: {attachment_dir_path}"
|
||||
logger.debug(
|
||||
f"Cleaning up attachments for note {note_id} in category '{category}'"
|
||||
)
|
||||
try:
|
||||
delete_result = await self.delete_resource(path=attachment_dir_path)
|
||||
logger.info(
|
||||
f"WebDAV deletion for category '{category}' attachment directory: {delete_result}"
|
||||
)
|
||||
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
|
||||
return delete_result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed during WebDAV deletion for category '{category}' attachment directory: {e}"
|
||||
)
|
||||
logger.error(f"Failed cleaning up attachments for note {note_id}: {e}")
|
||||
raise e
|
||||
|
||||
async def add_note_attachment(
|
||||
@@ -123,14 +114,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
|
||||
attachment_path = f"{parent_dir_path}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}"
|
||||
)
|
||||
|
||||
# Log current auth settings
|
||||
logger.info(
|
||||
f"WebDAV auth settings - Username: {self.username}, Auth Type: {type(self._client.auth).__name__}"
|
||||
)
|
||||
logger.debug(f"Uploading attachment '{filename}' for note {note_id}")
|
||||
|
||||
if not mime_type:
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
@@ -141,17 +125,13 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
try:
|
||||
# First check if we can access WebDAV at all
|
||||
notes_dir_path = f"{webdav_base}/Notes"
|
||||
logger.info(f"Testing WebDAV access to Notes directory: {notes_dir_path}")
|
||||
|
||||
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
|
||||
notes_dir_response = await self._client.request(
|
||||
"PROPFIND", notes_dir_path, headers=propfind_headers
|
||||
)
|
||||
|
||||
if notes_dir_response.status_code == 401:
|
||||
logger.error(
|
||||
"WebDAV authentication failed for Notes directory. Please verify WebDAV permissions."
|
||||
)
|
||||
logger.error("WebDAV authentication failed for Notes directory")
|
||||
raise HTTPStatusError(
|
||||
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
|
||||
request=notes_dir_response.request,
|
||||
@@ -162,13 +142,8 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
|
||||
)
|
||||
notes_dir_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Successfully accessed WebDAV Notes directory (Status: {notes_dir_response.status_code})"
|
||||
)
|
||||
|
||||
# Ensure the parent directory exists using MKCOL
|
||||
logger.info(f"Ensuring attachments directory exists: {parent_dir_path}")
|
||||
mkcol_headers = {"OCS-APIRequest": "true"}
|
||||
mkcol_response = await self._client.request(
|
||||
"MKCOL", parent_dir_path, headers=mkcol_headers
|
||||
@@ -176,23 +151,18 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
|
||||
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
|
||||
if mkcol_response.status_code not in [201, 405]:
|
||||
logger.warning(
|
||||
logger.error(
|
||||
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
|
||||
)
|
||||
mkcol_response.raise_for_status()
|
||||
else:
|
||||
logger.info(
|
||||
f"Created/verified directory: {parent_dir_path} (Status: {mkcol_response.status_code})"
|
||||
)
|
||||
|
||||
# Proceed with the PUT request
|
||||
logger.info(f"Putting attachment file to: {attachment_path}")
|
||||
response = await self._client.put(
|
||||
attachment_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id} (Status: {response.status_code})"
|
||||
logger.debug(
|
||||
f"Successfully uploaded attachment '{filename}' to note {note_id}"
|
||||
)
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
@@ -216,9 +186,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
attachment_dir_segment = f".attachments.{note_id}"
|
||||
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
|
||||
|
||||
logger.info(
|
||||
f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}"
|
||||
)
|
||||
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
|
||||
|
||||
try:
|
||||
response = await self._client.get(attachment_path)
|
||||
@@ -227,18 +195,223 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
content = response.content
|
||||
mime_type = response.headers.get("content-type", "application/octet-stream")
|
||||
|
||||
logger.info(
|
||||
f"Successfully fetched attachment '{filename}' ({mime_type}, {len(content)} bytes)"
|
||||
logger.debug(
|
||||
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
|
||||
)
|
||||
return content, mime_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
|
||||
else:
|
||||
logger.error(
|
||||
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
|
||||
"""List files and directories in the specified path via WebDAV PROPFIND."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Listing directory: {path}")
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND", webdav_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
items = []
|
||||
|
||||
# Skip the first response (the directory itself)
|
||||
responses = root.findall(".//{DAV:}response")[1:]
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory name from href
|
||||
href_text = href.text or ""
|
||||
name = href_text.rstrip("/").split("/")[-1]
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Determine if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Get other properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = (
|
||||
int(size_elem.text)
|
||||
if size_elem is not None and size_elem.text
|
||||
else 0
|
||||
)
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = (
|
||||
content_type_elem.text if content_type_elem is not None else None
|
||||
)
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
items.append(
|
||||
{
|
||||
"name": name,
|
||||
"path": f"{path.rstrip('/')}/{name}" if path else name,
|
||||
"is_directory": is_directory,
|
||||
"size": size if not is_directory else None,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(items)} items in directory: {path}")
|
||||
return items
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
|
||||
raise e
|
||||
|
||||
async def read_file(self, path: str) -> Tuple[bytes, str]:
|
||||
"""Read a file's content via WebDAV GET."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Reading file: {path}")
|
||||
|
||||
try:
|
||||
response = await self._client.get(webdav_path)
|
||||
response.raise_for_status()
|
||||
|
||||
content = response.content
|
||||
content_type = response.headers.get(
|
||||
"content-type", "application/octet-stream"
|
||||
)
|
||||
|
||||
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
|
||||
return content, content_type
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error reading file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error reading file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: bytes, content_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Write content to a file via WebDAV PUT."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
logger.debug(f"Writing file: {path}")
|
||||
|
||||
if not content_type:
|
||||
content_type, _ = mimetypes.guess_type(path)
|
||||
if not content_type:
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.put(
|
||||
webdav_path, content=content, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully wrote file '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error writing file '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error writing file '{path}': {e}")
|
||||
raise e
|
||||
|
||||
async def create_directory(
|
||||
self, path: str, recursive: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a directory via WebDAV MKCOL."""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
if not webdav_path.endswith("/"):
|
||||
webdav_path += "/"
|
||||
|
||||
logger.debug(f"Creating directory: {path}")
|
||||
|
||||
headers = {"OCS-APIRequest": "true"}
|
||||
|
||||
try:
|
||||
response = await self._client.request("MKCOL", webdav_path, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.debug(f"Successfully created directory '{path}'")
|
||||
return {"status_code": response.status_code}
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# Method Not Allowed - directory already exists
|
||||
if e.response.status_code == 405:
|
||||
logger.debug(f"Directory '{path}' already exists")
|
||||
return {"status_code": 405, "message": "Directory already exists"}
|
||||
|
||||
# File Conflict - parent directory does not exist
|
||||
if e.response.status_code == 409 and recursive:
|
||||
# Extract parent directory path
|
||||
path_parts = path.strip("/").split("/")
|
||||
if len(path_parts) > 1:
|
||||
parent_dir = "/".join(path_parts[:-1])
|
||||
logger.debug(
|
||||
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
|
||||
)
|
||||
await self.create_directory(parent_dir, recursive)
|
||||
# Now try to create the original directory again
|
||||
return await self.create_directory(path, recursive)
|
||||
else:
|
||||
# This shouldn't happen for single-level directories under root
|
||||
logger.error(f"409 conflict for single-level directory '{path}'")
|
||||
raise e
|
||||
|
||||
logger.error(f"HTTP error creating directory '{path}': {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error creating directory '{path}': {e}")
|
||||
raise e
|
||||
|
||||
@@ -38,7 +38,7 @@ logger = logging.getLogger(__name__)
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
"""Get the Nextcloud Host capabilities"""
|
||||
ctx = (
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
@@ -48,7 +48,7 @@ async def nc_get_capabilities():
|
||||
@mcp.resource("notes://settings")
|
||||
async def notes_get_settings():
|
||||
"""Get the Notes App settings"""
|
||||
ctx = (
|
||||
ctx: Context = (
|
||||
mcp.get_context()
|
||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
@@ -133,9 +133,9 @@ async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
@mcp.tool()
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
ctx: Context = None,
|
||||
):
|
||||
"""Read rows from a table with optional pagination"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
@@ -172,7 +172,7 @@ async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
|
||||
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
"""Get a specific attachment from a note"""
|
||||
ctx = mcp.get_context()
|
||||
ctx: Context = mcp.get_context()
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
# Assuming a method get_note_attachment exists in the client
|
||||
# This method should return the raw content and determine the mime type
|
||||
@@ -191,6 +191,153 @@ async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
|
||||
}
|
||||
|
||||
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
|
||||
Examples:
|
||||
# List root directory
|
||||
await nc_webdav_list_directory("")
|
||||
|
||||
# List a specific folder
|
||||
await nc_webdav_list_directory("Documents/Projects")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.list_directory(path)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path to the file to read
|
||||
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
print(result['content']) # Decoded text content
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
print(result['encoding']) # 'base64'
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# For text files, decode content for easier viewing
|
||||
if content_type and content_type.startswith("text/"):
|
||||
try:
|
||||
decoded_content = content.decode("utf-8")
|
||||
return {
|
||||
"path": path,
|
||||
"content": decoded_content,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
}
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# For binary files, return metadata and base64 encoded content
|
||||
import base64
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"content": base64.b64encode(content).decode("ascii"),
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
"""Write content to a file in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path where to write the file
|
||||
content: File content (text or base64 for binary)
|
||||
content_type: MIME type (auto-detected if not provided, use 'type;base64' for binary)
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating success
|
||||
|
||||
Examples:
|
||||
# Write a text file
|
||||
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
|
||||
|
||||
# Write binary data (base64 encoded)
|
||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
import base64
|
||||
|
||||
content_bytes = base64.b64decode(content)
|
||||
content_type = content_type.replace(";base64", "")
|
||||
else:
|
||||
content_bytes = content.encode("utf-8")
|
||||
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the directory to create
|
||||
|
||||
Returns:
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
|
||||
Examples:
|
||||
# Create a single directory
|
||||
await nc_webdav_create_directory("NewProject")
|
||||
|
||||
# Create nested directories (parent must exist)
|
||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
Args:
|
||||
path: Full path of the file or directory to delete
|
||||
|
||||
Returns:
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
|
||||
Examples:
|
||||
# Delete a file
|
||||
await nc_webdav_delete_resource("old_document.txt")
|
||||
|
||||
# Delete a directory (will delete all contents)
|
||||
await nc_webdav_delete_resource("temp_folder")
|
||||
"""
|
||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
|
||||
def run():
|
||||
mcp.run()
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Integration tests for WebDAV operations."""
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
import uuid
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_base_path(nc_client: NextcloudClient):
|
||||
"""Base path for test files/directories."""
|
||||
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
yield test_dir
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
|
||||
|
||||
async def test_create_and_delete_directory(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating and deleting directories."""
|
||||
test_dir = f"{test_base_path}/test_directory"
|
||||
|
||||
try:
|
||||
# Create directory
|
||||
result = await nc_client.webdav.create_directory(test_dir)
|
||||
assert result["status_code"] == 201 # Created
|
||||
logger.info(f"Created directory: {test_dir}")
|
||||
|
||||
# Verify directory exists by listing parent
|
||||
parent_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
dir_names = [item["name"] for item in parent_listing]
|
||||
assert "test_directory" in dir_names
|
||||
|
||||
# Delete directory
|
||||
delete_result = await nc_client.webdav.delete_resource(test_dir)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted directory: {test_dir}")
|
||||
|
||||
finally:
|
||||
# Cleanup: ensure directory is deleted
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test writing, reading, and deleting files."""
|
||||
test_file = f"{test_base_path}/test_file.txt"
|
||||
test_content = f"Test content {uuid.uuid4().hex}"
|
||||
|
||||
try:
|
||||
# Create base directory first
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write file
|
||||
write_result = await nc_client.webdav.write_file(
|
||||
test_file, test_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert write_result["status_code"] in [200, 201, 204] # Success codes
|
||||
logger.info(f"Wrote file: {test_file}")
|
||||
|
||||
# Read file back
|
||||
content, content_type = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == test_content
|
||||
assert "text/plain" in content_type
|
||||
logger.info(f"Read file: {test_file}")
|
||||
|
||||
# Verify file appears in directory listing
|
||||
listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
file_names = [item["name"] for item in listing]
|
||||
assert "test_file.txt" in file_names
|
||||
|
||||
# Delete file
|
||||
delete_result = await nc_client.webdav.delete_resource(test_file)
|
||||
assert delete_result["status_code"] in [204, 404] # No Content or Not Found
|
||||
logger.info(f"Deleted file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_directory_empty_and_populated(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test listing empty and populated directories."""
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# List empty directory
|
||||
empty_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert isinstance(empty_listing, list)
|
||||
assert len(empty_listing) == 0
|
||||
logger.info(f"Empty directory listing: {len(empty_listing)} items")
|
||||
|
||||
# Add some files and directories
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.create_directory(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file1.txt", b"content1", content_type="text/plain"
|
||||
)
|
||||
await nc_client.webdav.write_file(
|
||||
f"{test_base_path}/file2.md",
|
||||
b"# Markdown content",
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
# List populated directory
|
||||
populated_listing = await nc_client.webdav.list_directory(test_base_path)
|
||||
assert len(populated_listing) == 4 # 2 dirs + 2 files
|
||||
|
||||
# Check that we have both files and directories
|
||||
names = [item["name"] for item in populated_listing]
|
||||
assert "subdir1" in names
|
||||
assert "subdir2" in names
|
||||
assert "file1.txt" in names
|
||||
assert "file2.md" in names
|
||||
|
||||
# Check metadata is present
|
||||
for item in populated_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Populated directory listing: {len(populated_listing)} items")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file1.txt")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/file2.md")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir1")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/subdir2")
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_read_nonexistent_file(nc_client: NextcloudClient):
|
||||
"""Test reading a file that doesn't exist."""
|
||||
nonexistent_file = f"nonexistent_{uuid.uuid4().hex}.txt"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
await nc_client.webdav.read_file(nonexistent_file)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent file: {nonexistent_file}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_resource(nc_client: NextcloudClient):
|
||||
"""Test deleting a resource that doesn't exist."""
|
||||
nonexistent_resource = f"nonexistent_{uuid.uuid4().hex}"
|
||||
|
||||
result = await nc_client.webdav.delete_resource(nonexistent_resource)
|
||||
assert result["status_code"] == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent resource: {nonexistent_resource}")
|
||||
|
||||
|
||||
async def test_create_nested_directories(
|
||||
nc_client: NextcloudClient, test_base_path: str
|
||||
):
|
||||
"""Test creating nested directory structures."""
|
||||
nested_path = f"{test_base_path}/level1/level2/level3"
|
||||
|
||||
try:
|
||||
# Create nested directories (should create parent directories automatically)
|
||||
result = await nc_client.webdav.create_directory(nested_path, True)
|
||||
assert result["status_code"] == 201
|
||||
|
||||
# Verify the structure was created
|
||||
level1_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1"
|
||||
)
|
||||
assert len(level1_listing) == 1
|
||||
assert level1_listing[0]["name"] == "level2"
|
||||
assert level1_listing[0]["is_directory"] is True
|
||||
|
||||
level2_listing = await nc_client.webdav.list_directory(
|
||||
f"{test_base_path}/level1/level2"
|
||||
)
|
||||
assert len(level2_listing) == 1
|
||||
assert level2_listing[0]["name"] == "level3"
|
||||
assert level2_listing[0]["is_directory"] is True
|
||||
|
||||
logger.info(f"Created nested directory structure: {nested_path}")
|
||||
|
||||
finally:
|
||||
# Cleanup - delete from deepest to shallowest
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(nested_path)
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
|
||||
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_overwrite_existing_file(nc_client: NextcloudClient, test_base_path: str):
|
||||
"""Test overwriting an existing file."""
|
||||
test_file = f"{test_base_path}/overwrite_test.txt"
|
||||
original_content = "Original content"
|
||||
new_content = "New content after overwrite"
|
||||
|
||||
try:
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_base_path)
|
||||
|
||||
# Write original file
|
||||
await nc_client.webdav.write_file(
|
||||
test_file, original_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
|
||||
# Verify original content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == original_content
|
||||
|
||||
# Overwrite with new content
|
||||
overwrite_result = await nc_client.webdav.write_file(
|
||||
test_file, new_content.encode("utf-8"), content_type="text/plain"
|
||||
)
|
||||
assert overwrite_result["status_code"] in [200, 204] # OK or No Content
|
||||
|
||||
# Verify new content
|
||||
content, _ = await nc_client.webdav.read_file(test_file)
|
||||
assert content.decode("utf-8") == new_content
|
||||
|
||||
logger.info(f"Successfully overwrote file: {test_file}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_file)
|
||||
await nc_client.webdav.delete_resource(test_base_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_list_root_directory(nc_client: NextcloudClient):
|
||||
"""Test listing the root directory."""
|
||||
root_listing = await nc_client.webdav.list_directory("")
|
||||
|
||||
# Root directory should exist and be listable
|
||||
assert isinstance(root_listing, list)
|
||||
# Should have at least some default folders/files
|
||||
assert len(root_listing) >= 0
|
||||
|
||||
# Check structure of items
|
||||
for item in root_listing:
|
||||
assert "name" in item
|
||||
assert "path" in item
|
||||
assert "is_directory" in item
|
||||
assert "size" in item
|
||||
assert "content_type" in item
|
||||
assert "last_modified" in item
|
||||
|
||||
logger.info(f"Root directory contains {len(root_listing)} items")
|
||||
Reference in New Issue
Block a user