Skip to content

Commit 2d3cb85

Browse files
authored
Merge pull request #92 from neovasky/master
feat(webdav): add complete file system support
2 parents 442e82e + 50c1215 commit 2d3cb85

File tree

6 files changed

+682
-5
lines changed

6 files changed

+682
-5
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
__pycache__/
22
.coverage
3+
.env
4+
*.env
5+
.env.local
6+
.env.*.local

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
## [Unreleased]
2+
3+
### Feat
4+
5+
- **webdav**: Add complete file system support with directory browsing, file read/write, and resource management
6+
- **webdav**: Add `nc_webdav_list_directory` tool for browsing any NextCloud directory
7+
- **webdav**: Add `nc_webdav_read_file` tool with automatic text/binary content handling
8+
- **webdav**: Add `nc_webdav_write_file` tool supporting text and base64 binary content
9+
- **webdav**: Add `nc_webdav_create_directory` tool for creating directories
10+
- **webdav**: Add `nc_webdav_delete_resource` tool for deleting files and directories
11+
- **webdav**: Add XML parsing for WebDAV PROPFIND responses with metadata extraction
12+
13+
### Fix
14+
15+
- **types**: Improve type annotations throughout codebase for better IDE support
16+
- **types**: Fix Context parameter ordering in MCP tools (required before optional)
17+
- **types**: Add proper type hints for WebDAV client methods
18+
19+
### Refactor
20+
21+
- **webdav**: Extend WebDAV client beyond Notes attachments to general file operations
22+
- **server**: Enhance error handling and logging for WebDAV operations
23+
124
## v0.4.1 (2025-07-10)
225

326
### Fix

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
1414
|-----|----------------|-------------|
1515
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
1616
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
17+
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
1718

1819
## Available Tools
1920

@@ -39,6 +40,16 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
3940
| `nc_tables_update_row` | Update an existing row in a table |
4041
| `nc_tables_delete_row` | Delete a row from a table |
4142

43+
### WebDAV File System Tools
44+
45+
| Tool | Description |
46+
|------|-------------|
47+
| `nc_webdav_list_directory` | List files and directories in any NextCloud path |
48+
| `nc_webdav_read_file` | Read file content (text files decoded, binary as base64) |
49+
| `nc_webdav_write_file` | Create or update files in NextCloud |
50+
| `nc_webdav_create_directory` | Create new directories |
51+
| `nc_webdav_delete_resource` | Delete files or directories |
52+
4253
## Available Resources
4354

4455
| Resource | Description |
@@ -47,6 +58,37 @@ The server provides integration with multiple Nextcloud apps, enabling LLMs to i
4758
| `notes://settings` | Access Notes app settings |
4859
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
4960

61+
### WebDAV File System Access
62+
63+
The server provides complete file system access to your NextCloud instance, enabling you to:
64+
65+
- Browse any directory structure
66+
- Read and write files of any type
67+
- Create and delete directories
68+
- Manage your NextCloud files directly through LLM interactions
69+
70+
**Usage Examples:**
71+
72+
```python
73+
# List files in root directory
74+
await nc_webdav_list_directory("")
75+
76+
# Browse a specific folder
77+
await nc_webdav_list_directory("Documents/Projects")
78+
79+
# Read a text file
80+
content = await nc_webdav_read_file("Documents/readme.txt")
81+
82+
# Create a new directory
83+
await nc_webdav_create_directory("NewProject/docs")
84+
85+
# Write content to a file
86+
await nc_webdav_write_file("NewProject/docs/notes.md", "# My Notes\n\nContent here...")
87+
88+
# Delete a file or directory
89+
await nc_webdav_delete_resource("old_file.txt")
90+
```
91+
5092
### Note Attachments
5193

5294
This server supports adding and retrieving note attachments via WebDAV. Please note the following behavior regarding attachments:

nextcloud_mcp_server/client/webdav.py

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""WebDAV client for Nextcloud file operations."""
22

33
import mimetypes
4-
from typing import Tuple, Dict, Any, Optional
4+
from typing import Tuple, Dict, Any, Optional, List
55
import logging
66
from httpx import HTTPStatusError
7+
import xml.etree.ElementTree as ET
78

89
from .base import BaseNextcloudClient
910

@@ -242,3 +243,192 @@ async def get_note_attachment(
242243
f"Unexpected error fetching attachment '{filename}' for note {note_id}: {e}"
243244
)
244245
raise e
246+
247+
async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
248+
"""List files and directories in the specified path via WebDAV PROPFIND."""
249+
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
250+
if not webdav_path.endswith("/"):
251+
webdav_path += "/"
252+
253+
logger.info(f"Listing directory: {webdav_path}")
254+
255+
propfind_body = """<?xml version="1.0"?>
256+
<d:propfind xmlns:d="DAV:">
257+
<d:prop>
258+
<d:displayname/>
259+
<d:getcontentlength/>
260+
<d:getcontenttype/>
261+
<d:getlastmodified/>
262+
<d:resourcetype/>
263+
</d:prop>
264+
</d:propfind>"""
265+
266+
headers = {"Depth": "1", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
267+
268+
try:
269+
response = await self._client.request(
270+
"PROPFIND", webdav_path, content=propfind_body, headers=headers
271+
)
272+
response.raise_for_status()
273+
274+
# Parse the XML response
275+
root = ET.fromstring(response.content)
276+
items = []
277+
278+
# Skip the first response (the directory itself)
279+
responses = root.findall(".//{DAV:}response")[1:]
280+
281+
for response_elem in responses:
282+
href = response_elem.find(".//{DAV:}href")
283+
if href is None:
284+
continue
285+
286+
# Extract file/directory name from href
287+
href_text = href.text or ""
288+
name = href_text.rstrip("/").split("/")[-1]
289+
if not name:
290+
continue
291+
292+
# Get properties
293+
propstat = response_elem.find(".//{DAV:}propstat")
294+
if propstat is None:
295+
continue
296+
297+
prop = propstat.find(".//{DAV:}prop")
298+
if prop is None:
299+
continue
300+
301+
# Determine if it's a directory
302+
resourcetype = prop.find(".//{DAV:}resourcetype")
303+
is_directory = (
304+
resourcetype is not None
305+
and resourcetype.find(".//{DAV:}collection") is not None
306+
)
307+
308+
# Get other properties
309+
size_elem = prop.find(".//{DAV:}getcontentlength")
310+
size = (
311+
int(size_elem.text)
312+
if size_elem is not None and size_elem.text
313+
else 0
314+
)
315+
316+
content_type_elem = prop.find(".//{DAV:}getcontenttype")
317+
content_type = (
318+
content_type_elem.text if content_type_elem is not None else None
319+
)
320+
321+
modified_elem = prop.find(".//{DAV:}getlastmodified")
322+
modified = modified_elem.text if modified_elem is not None else None
323+
324+
items.append(
325+
{
326+
"name": name,
327+
"path": f"{path.rstrip('/')}/{name}" if path else name,
328+
"is_directory": is_directory,
329+
"size": size if not is_directory else None,
330+
"content_type": content_type,
331+
"last_modified": modified,
332+
}
333+
)
334+
335+
logger.info(f"Found {len(items)} items in directory: {webdav_path}")
336+
return items
337+
338+
except HTTPStatusError as e:
339+
logger.error(f"HTTP error listing directory '{webdav_path}': {e}")
340+
raise e
341+
except Exception as e:
342+
logger.error(f"Unexpected error listing directory '{webdav_path}': {e}")
343+
raise e
344+
345+
async def read_file(self, path: str) -> Tuple[bytes, str]:
346+
"""Read a file's content via WebDAV GET."""
347+
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
348+
349+
logger.info(f"Reading file: {webdav_path}")
350+
351+
try:
352+
response = await self._client.get(webdav_path)
353+
response.raise_for_status()
354+
355+
content = response.content
356+
content_type = response.headers.get(
357+
"content-type", "application/octet-stream"
358+
)
359+
360+
logger.info(
361+
f"Successfully read file '{path}' ({content_type}, {len(content)} bytes)"
362+
)
363+
return content, content_type
364+
365+
except HTTPStatusError as e:
366+
logger.error(f"HTTP error reading file '{path}': {e}")
367+
raise e
368+
except Exception as e:
369+
logger.error(f"Unexpected error reading file '{path}': {e}")
370+
raise e
371+
372+
async def write_file(
373+
self, path: str, content: bytes, content_type: Optional[str] = None
374+
) -> Dict[str, Any]:
375+
"""Write content to a file via WebDAV PUT."""
376+
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
377+
378+
logger.info(f"Writing file: {webdav_path}")
379+
380+
if not content_type:
381+
content_type, _ = mimetypes.guess_type(path)
382+
if not content_type:
383+
content_type = "application/octet-stream"
384+
385+
headers = {"Content-Type": content_type, "OCS-APIRequest": "true"}
386+
387+
try:
388+
response = await self._client.put(
389+
webdav_path, content=content, headers=headers
390+
)
391+
response.raise_for_status()
392+
393+
logger.info(
394+
f"Successfully wrote file '{path}' (Status: {response.status_code})"
395+
)
396+
return {"status_code": response.status_code}
397+
398+
except HTTPStatusError as e:
399+
logger.error(f"HTTP error writing file '{path}': {e}")
400+
raise e
401+
except Exception as e:
402+
logger.error(f"Unexpected error writing file '{path}': {e}")
403+
raise e
404+
405+
async def create_directory(self, path: str) -> Dict[str, Any]:
406+
"""Create a directory via WebDAV MKCOL."""
407+
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
408+
if not webdav_path.endswith("/"):
409+
webdav_path += "/"
410+
411+
logger.info(f"Creating directory: {webdav_path}")
412+
413+
headers = {"OCS-APIRequest": "true"}
414+
415+
try:
416+
response = await self._client.request("MKCOL", webdav_path, headers=headers)
417+
response.raise_for_status()
418+
419+
logger.info(
420+
f"Successfully created directory '{path}' (Status: {response.status_code})"
421+
)
422+
return {"status_code": response.status_code}
423+
424+
except HTTPStatusError as e:
425+
if (
426+
e.response.status_code == 405
427+
): # Method Not Allowed - directory already exists
428+
logger.info(f"Directory '{path}' already exists")
429+
return {"status_code": 405, "message": "Directory already exists"}
430+
logger.error(f"HTTP error creating directory '{path}': {e}")
431+
raise e
432+
except Exception as e:
433+
logger.error(f"Unexpected error creating directory '{path}': {e}")
434+
raise e

0 commit comments

Comments
 (0)