Skip to content

Commit 3ad07d0

Browse files
committed
feat: Update webdav client create_directory method to handle recursive directories
1 parent 50c1215 commit 3ad07d0

File tree

2 files changed

+70
-86
lines changed

2 files changed

+70
-86
lines changed

nextcloud_mcp_server/client/webdav.py

Lines changed: 63 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async def delete_resource(self, path: str) -> Dict[str, Any]:
2323
path_with_slash = path
2424

2525
webdav_path = f"{self._get_webdav_base_path()}/{path_with_slash.lstrip('/')}"
26-
logger.info(f"Deleting WebDAV resource: {webdav_path}")
26+
logger.debug(f"Deleting WebDAV resource: {webdav_path}")
2727

2828
headers = {"OCS-APIRequest": "true"}
2929
try:
@@ -33,36 +33,30 @@ async def delete_resource(self, path: str) -> Dict[str, Any]:
3333
propfind_resp = await self._client.request(
3434
"PROPFIND", webdav_path, headers=propfind_headers
3535
)
36-
logger.info(
37-
f"Resource exists check (PROPFIND) status: {propfind_resp.status_code}"
36+
logger.debug(
37+
f"Resource exists check status: {propfind_resp.status_code}"
3838
)
3939
except HTTPStatusError as e:
4040
if e.response.status_code == 404:
41-
logger.info(
42-
f"Resource '{webdav_path}' doesn't exist, no deletion needed."
43-
)
41+
logger.debug(f"Resource '{path}' doesn't exist, no deletion needed")
4442
return {"status_code": 404}
4543
# For other errors, continue with deletion attempt
4644

4745
# Proceed with deletion
4846
response = await self._client.delete(webdav_path, headers=headers)
4947
response.raise_for_status()
50-
logger.info(
51-
f"Successfully deleted WebDAV resource '{webdav_path}' (Status: {response.status_code})"
52-
)
48+
logger.debug(f"Successfully deleted WebDAV resource '{path}'")
5349
return {"status_code": response.status_code}
5450

5551
except HTTPStatusError as e:
56-
logger.warning(f"HTTP error deleting WebDAV resource '{webdav_path}': {e}")
57-
if e.response.status_code != 404:
58-
raise e
59-
else:
60-
logger.info(f"Resource '{webdav_path}' not found, no deletion needed.")
52+
if e.response.status_code == 404:
53+
logger.debug(f"Resource '{path}' not found, no deletion needed")
6154
return {"status_code": 404}
55+
else:
56+
logger.error(f"HTTP error deleting WebDAV resource '{path}': {e}")
57+
raise e
6258
except Exception as e:
63-
logger.warning(
64-
f"Unexpected error deleting WebDAV resource '{webdav_path}': {e}"
65-
)
59+
logger.error(f"Unexpected error deleting WebDAV resource '{path}': {e}")
6660
raise e
6761

6862
async def cleanup_old_attachment_directory(
@@ -74,10 +68,10 @@ async def cleanup_old_attachment_directory(
7468
f"Notes/{old_category_path_part}.attachments.{note_id}/"
7569
)
7670

77-
logger.info(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
71+
logger.debug(f"Cleaning up old attachment directory: {old_attachment_dir_path}")
7872
try:
7973
delete_result = await self.delete_resource(path=old_attachment_dir_path)
80-
logger.info(f"Cleanup of old attachment directory result: {delete_result}")
74+
logger.debug(f"Cleanup result: {delete_result}")
8175
return delete_result
8276
except Exception as e:
8377
logger.error(f"Error during cleanup of old attachment directory: {e}")
@@ -90,19 +84,15 @@ async def cleanup_note_attachments(
9084
cat_path_part = f"{category}/" if category else ""
9185
attachment_dir_path = f"Notes/{cat_path_part}.attachments.{note_id}/"
9286

93-
logger.info(
94-
f"Attempting to delete attachment directory for note {note_id} in category '{category}' via WebDAV: {attachment_dir_path}"
87+
logger.debug(
88+
f"Cleaning up attachments for note {note_id} in category '{category}'"
9589
)
9690
try:
9791
delete_result = await self.delete_resource(path=attachment_dir_path)
98-
logger.info(
99-
f"WebDAV deletion for category '{category}' attachment directory: {delete_result}"
100-
)
92+
logger.debug(f"Cleanup result for note {note_id}: {delete_result}")
10193
return delete_result
10294
except Exception as e:
103-
logger.warning(
104-
f"Failed during WebDAV deletion for category '{category}' attachment directory: {e}"
105-
)
95+
logger.error(f"Failed cleaning up attachments for note {note_id}: {e}")
10696
raise e
10797

10898
async def add_note_attachment(
@@ -124,14 +114,7 @@ async def add_note_attachment(
124114
parent_dir_path = f"{webdav_base}/{parent_dir_webdav_rel_path}"
125115
attachment_path = f"{parent_dir_path}/{filename}"
126116

127-
logger.info(
128-
f"Uploading attachment for note {note_id} (category: '{category or ''}') to WebDAV path: {attachment_path}"
129-
)
130-
131-
# Log current auth settings
132-
logger.info(
133-
f"WebDAV auth settings - Username: {self.username}, Auth Type: {type(self._client.auth).__name__}"
134-
)
117+
logger.debug(f"Uploading attachment '{filename}' for note {note_id}")
135118

136119
if not mime_type:
137120
mime_type, _ = mimetypes.guess_type(filename)
@@ -142,17 +125,13 @@ async def add_note_attachment(
142125
try:
143126
# First check if we can access WebDAV at all
144127
notes_dir_path = f"{webdav_base}/Notes"
145-
logger.info(f"Testing WebDAV access to Notes directory: {notes_dir_path}")
146-
147128
propfind_headers = {"Depth": "0", "OCS-APIRequest": "true"}
148129
notes_dir_response = await self._client.request(
149130
"PROPFIND", notes_dir_path, headers=propfind_headers
150131
)
151132

152133
if notes_dir_response.status_code == 401:
153-
logger.error(
154-
"WebDAV authentication failed for Notes directory. Please verify WebDAV permissions."
155-
)
134+
logger.error("WebDAV authentication failed for Notes directory")
156135
raise HTTPStatusError(
157136
f"Authentication error accessing WebDAV Notes directory: {notes_dir_response.status_code}",
158137
request=notes_dir_response.request,
@@ -163,37 +142,27 @@ async def add_note_attachment(
163142
f"Error accessing WebDAV Notes directory: {notes_dir_response.status_code}"
164143
)
165144
notes_dir_response.raise_for_status()
166-
else:
167-
logger.info(
168-
f"Successfully accessed WebDAV Notes directory (Status: {notes_dir_response.status_code})"
169-
)
170145

171146
# Ensure the parent directory exists using MKCOL
172-
logger.info(f"Ensuring attachments directory exists: {parent_dir_path}")
173147
mkcol_headers = {"OCS-APIRequest": "true"}
174148
mkcol_response = await self._client.request(
175149
"MKCOL", parent_dir_path, headers=mkcol_headers
176150
)
177151

178152
# MKCOL should return 201 Created or 405 Method Not Allowed (if directory already exists)
179153
if mkcol_response.status_code not in [201, 405]:
180-
logger.warning(
154+
logger.error(
181155
f"Unexpected status code {mkcol_response.status_code} when creating attachments directory"
182156
)
183157
mkcol_response.raise_for_status()
184-
else:
185-
logger.info(
186-
f"Created/verified directory: {parent_dir_path} (Status: {mkcol_response.status_code})"
187-
)
188158

189159
# Proceed with the PUT request
190-
logger.info(f"Putting attachment file to: {attachment_path}")
191160
response = await self._client.put(
192161
attachment_path, content=content, headers=headers
193162
)
194163
response.raise_for_status()
195-
logger.info(
196-
f"Successfully uploaded attachment '{filename}' to note {note_id} (Status: {response.status_code})"
164+
logger.debug(
165+
f"Successfully uploaded attachment '{filename}' to note {note_id}"
197166
)
198167
return {"status_code": response.status_code}
199168

@@ -217,9 +186,7 @@ async def get_note_attachment(
217186
attachment_dir_segment = f".attachments.{note_id}"
218187
attachment_path = f"{webdav_base}/Notes/{category_path_part}{attachment_dir_segment}/{filename}"
219188

220-
logger.info(
221-
f"Fetching attachment for note {note_id} (category: '{category or ''}') from WebDAV path: {attachment_path}"
222-
)
189+
logger.debug(f"Fetching attachment '{filename}' for note {note_id}")
223190

224191
try:
225192
response = await self._client.get(attachment_path)
@@ -228,15 +195,18 @@ async def get_note_attachment(
228195
content = response.content
229196
mime_type = response.headers.get("content-type", "application/octet-stream")
230197

231-
logger.info(
232-
f"Successfully fetched attachment '{filename}' ({mime_type}, {len(content)} bytes)"
198+
logger.debug(
199+
f"Successfully fetched attachment '{filename}' ({len(content)} bytes)"
233200
)
234201
return content, mime_type
235202

236203
except HTTPStatusError as e:
237-
logger.error(
238-
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
239-
)
204+
if e.response.status_code == 404:
205+
logger.debug(f"Attachment '{filename}' not found for note {note_id}")
206+
else:
207+
logger.error(
208+
f"HTTP error fetching attachment '{filename}' for note {note_id}: {e}"
209+
)
240210
raise e
241211
except Exception as e:
242212
logger.error(
@@ -250,7 +220,7 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
250220
if not webdav_path.endswith("/"):
251221
webdav_path += "/"
252222

253-
logger.info(f"Listing directory: {webdav_path}")
223+
logger.debug(f"Listing directory: {path}")
254224

255225
propfind_body = """<?xml version="1.0"?>
256226
<d:propfind xmlns:d="DAV:">
@@ -332,7 +302,7 @@ async def list_directory(self, path: str = "") -> List[Dict[str, Any]]:
332302
}
333303
)
334304

335-
logger.info(f"Found {len(items)} items in directory: {webdav_path}")
305+
logger.debug(f"Found {len(items)} items in directory: {path}")
336306
return items
337307

338308
except HTTPStatusError as e:
@@ -346,7 +316,7 @@ async def read_file(self, path: str) -> Tuple[bytes, str]:
346316
"""Read a file's content via WebDAV GET."""
347317
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
348318

349-
logger.info(f"Reading file: {webdav_path}")
319+
logger.debug(f"Reading file: {path}")
350320

351321
try:
352322
response = await self._client.get(webdav_path)
@@ -357,9 +327,7 @@ async def read_file(self, path: str) -> Tuple[bytes, str]:
357327
"content-type", "application/octet-stream"
358328
)
359329

360-
logger.info(
361-
f"Successfully read file '{path}' ({content_type}, {len(content)} bytes)"
362-
)
330+
logger.debug(f"Successfully read file '{path}' ({len(content)} bytes)")
363331
return content, content_type
364332

365333
except HTTPStatusError as e:
@@ -375,7 +343,7 @@ async def write_file(
375343
"""Write content to a file via WebDAV PUT."""
376344
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
377345

378-
logger.info(f"Writing file: {webdav_path}")
346+
logger.debug(f"Writing file: {path}")
379347

380348
if not content_type:
381349
content_type, _ = mimetypes.guess_type(path)
@@ -390,9 +358,7 @@ async def write_file(
390358
)
391359
response.raise_for_status()
392360

393-
logger.info(
394-
f"Successfully wrote file '{path}' (Status: {response.status_code})"
395-
)
361+
logger.debug(f"Successfully wrote file '{path}'")
396362
return {"status_code": response.status_code}
397363

398364
except HTTPStatusError as e:
@@ -402,31 +368,48 @@ async def write_file(
402368
logger.error(f"Unexpected error writing file '{path}': {e}")
403369
raise e
404370

405-
async def create_directory(self, path: str) -> Dict[str, Any]:
371+
async def create_directory(
372+
self, path: str, recursive: bool = False
373+
) -> Dict[str, Any]:
406374
"""Create a directory via WebDAV MKCOL."""
407375
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
408376
if not webdav_path.endswith("/"):
409377
webdav_path += "/"
410378

411-
logger.info(f"Creating directory: {webdav_path}")
379+
logger.debug(f"Creating directory: {path}")
412380

413381
headers = {"OCS-APIRequest": "true"}
414382

415383
try:
416384
response = await self._client.request("MKCOL", webdav_path, headers=headers)
417385
response.raise_for_status()
418386

419-
logger.info(
420-
f"Successfully created directory '{path}' (Status: {response.status_code})"
421-
)
387+
logger.debug(f"Successfully created directory '{path}'")
422388
return {"status_code": response.status_code}
423389

424390
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")
391+
# Method Not Allowed - directory already exists
392+
if e.response.status_code == 405:
393+
logger.debug(f"Directory '{path}' already exists")
429394
return {"status_code": 405, "message": "Directory already exists"}
395+
396+
# File Conflict - parent directory does not exist
397+
if e.response.status_code == 409 and recursive:
398+
# Extract parent directory path
399+
path_parts = path.strip("/").split("/")
400+
if len(path_parts) > 1:
401+
parent_dir = "/".join(path_parts[:-1])
402+
logger.debug(
403+
f"Parent directory '{parent_dir}' doesn't exist, creating recursively"
404+
)
405+
await self.create_directory(parent_dir, recursive)
406+
# Now try to create the original directory again
407+
return await self.create_directory(path, recursive)
408+
else:
409+
# This shouldn't happen for single-level directories under root
410+
logger.error(f"409 conflict for single-level directory '{path}'")
411+
raise e
412+
430413
logger.error(f"HTTP error creating directory '{path}': {e}")
431414
raise e
432415
except Exception as e:

tests/integration/test_webdav_operations.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414

1515

1616
@pytest.fixture
17-
def test_base_path():
17+
async def test_base_path(nc_client: NextcloudClient):
1818
"""Base path for test files/directories."""
19-
return f"mcp_test_{uuid.uuid4().hex[:8]}"
19+
test_dir = f"mcp_test_{uuid.uuid4().hex[:8]}"
20+
await nc_client.webdav.create_directory(test_dir)
21+
yield test_dir
22+
await nc_client.webdav.delete_resource(test_dir)
2023

2124

2225
async def test_create_and_delete_directory(
@@ -45,7 +48,6 @@ async def test_create_and_delete_directory(
4548
# Cleanup: ensure directory is deleted
4649
try:
4750
await nc_client.webdav.delete_resource(test_dir)
48-
await nc_client.webdav.delete_resource(test_base_path)
4951
except Exception:
5052
pass
5153

@@ -69,7 +71,7 @@ async def test_write_read_delete_file(nc_client: NextcloudClient, test_base_path
6971
# Read file back
7072
content, content_type = await nc_client.webdav.read_file(test_file)
7173
assert content.decode("utf-8") == test_content
72-
assert content_type == "text/plain"
74+
assert "text/plain" in content_type
7375
logger.info(f"Read file: {test_file}")
7476

7577
# Verify file appears in directory listing
@@ -179,7 +181,7 @@ async def test_create_nested_directories(
179181

180182
try:
181183
# Create nested directories (should create parent directories automatically)
182-
result = await nc_client.webdav.create_directory(nested_path)
184+
result = await nc_client.webdav.create_directory(nested_path, True)
183185
assert result["status_code"] == 201
184186

185187
# Verify the structure was created
@@ -205,7 +207,6 @@ async def test_create_nested_directories(
205207
await nc_client.webdav.delete_resource(nested_path)
206208
await nc_client.webdav.delete_resource(f"{test_base_path}/level1/level2")
207209
await nc_client.webdav.delete_resource(f"{test_base_path}/level1")
208-
await nc_client.webdav.delete_resource(test_base_path)
209210
except Exception:
210211
pass
211212

0 commit comments

Comments
 (0)