11"""File management tools — list, read, upload, copy, delete, move, search files via WebDAV."""
22
3+ import asyncio
34import base64
45import binascii
6+ import errno
7+ import io
58import json
69import mimetypes
10+ import os
11+ from collections .abc import AsyncIterator
12+ from pathlib import Path
713from xml .sax .saxutils import escape as xml_escape
814
915from mcp .server .fastmcp import FastMCP
2127
2228_IMAGE_MIME_TYPES = {"image/png" , "image/jpeg" , "image/gif" , "image/webp" , "image/bmp" , "image/svg+xml" }
2329_MAX_IMAGE_SIZE = 10 * 1024 * 1024
30+ _UPLOAD_CHUNK_SIZE = 256 * 1024
2431
2532
2633def _resolve_content_type (path : str , content_type : str ) -> str :
@@ -30,6 +37,72 @@ def _resolve_content_type(path: str, content_type: str) -> str:
3037 return guessed or "application/octet-stream"
3138
3239
40+ def _resolve_local_upload_path (local_path : str , upload_root : str ) -> Path :
41+ """Resolve a local path and verify it is inside the configured upload root.
42+
43+ Symlinks are resolved before the containment check, so a symlink inside the
44+ root that points outside is rejected.
45+
46+ Raises:
47+ ValueError: when upload_root is not configured, the path is empty, does
48+ not exist, is not a regular file, or resolves to a location outside
49+ the upload root.
50+ """
51+ if not upload_root :
52+ raise ValueError (
53+ "upload_file_from_path is not configured on this server. "
54+ "The administrator must set NEXTCLOUD_MCP_UPLOAD_ROOT to a local directory."
55+ )
56+ if not local_path or not local_path .strip ():
57+ raise ValueError ("local_path cannot be empty." )
58+ try :
59+ resolved = Path (local_path ).expanduser ().resolve (strict = True )
60+ except FileNotFoundError :
61+ raise ValueError (f"Local file not found: { local_path } " ) from None
62+ except (OSError , RuntimeError ):
63+ raise ValueError (f"Cannot resolve local path: { local_path } " ) from None
64+ root = Path (upload_root ).resolve (strict = False )
65+ try :
66+ resolved .relative_to (root )
67+ except ValueError :
68+ raise ValueError (f"Path '{ local_path } ' is outside the configured upload root." ) from None
69+ if not resolved .is_file ():
70+ raise ValueError (f"Path is not a regular file: { local_path } " )
71+ return resolved
72+
73+
74+ def _open_no_follow (path : Path ) -> io .FileIO :
75+ """Open a regular file for reading with O_NOFOLLOW as TOCTOU defense-in-depth.
76+
77+ _resolve_local_upload_path already rejects symlinks in the caller's input by
78+ resolving the path before the containment check. But if another local actor
79+ has write access to the upload root, they could replace the validated file
80+ with a symlink between validation and this open. O_NOFOLLOW on the final
81+ component closes that race window (intermediate components are not covered;
82+ see the tool docstring for the expected trust model).
83+ """
84+ try :
85+ fd = os .open (path , os .O_RDONLY | os .O_NOFOLLOW | os .O_CLOEXEC )
86+ except OSError as exc :
87+ if exc .errno in (errno .ELOOP , errno .EMLINK ):
88+ raise ValueError (f"Refusing to follow symlink at { path .name } : file was swapped after validation." ) from exc
89+ raise
90+ return io .FileIO (fd , closefd = True )
91+
92+
93+ async def _stream_local_file (path : Path , chunk_size : int = _UPLOAD_CHUNK_SIZE ) -> AsyncIterator [bytes ]:
94+ """Yield chunks from a local file without blocking the event loop."""
95+ f = await asyncio .to_thread (_open_no_follow , path )
96+ try :
97+ while True :
98+ chunk = await asyncio .to_thread (f .read , chunk_size )
99+ if not chunk :
100+ break
101+ yield chunk
102+ finally :
103+ await asyncio .to_thread (f .close )
104+
105+
33106def _build_search_xml (user : str , query : str , path : str , limit : int , offset : int , mimetype : str ) -> str :
34107 """Build a WebDAV SEARCH request body."""
35108 where_parts : list [str ] = []
@@ -274,6 +347,54 @@ async def create_directory(path: str) -> str:
274347 return f"Directory created: { path } "
275348
276349
350+ def _register_upload_from_path_tool (mcp : FastMCP ) -> None :
351+ @mcp .tool (annotations = ADDITIVE_IDEMPOTENT )
352+ @require_permission (PermissionLevel .WRITE )
353+ async def upload_file_from_path (local_path : str , remote_path : str , content_type : str = "" ) -> str :
354+ """Upload a local file from the server's filesystem to Nextcloud.
355+
356+ Suitable for large files — the content is streamed in chunks rather than
357+ loaded fully into memory. Contrast with upload_file (text-only) and
358+ upload_file_binary (whole content passed inline as base64).
359+
360+ The administrator must enable this tool by setting NEXTCLOUD_MCP_UPLOAD_ROOT
361+ to a local directory. Only files inside that directory can be uploaded
362+ (symlinks are resolved before the containment check). If the env var is
363+ not set, this tool is not registered at all.
364+
365+ Trust model: NEXTCLOUD_MCP_UPLOAD_ROOT should be a directory whose ancestors
366+ and contents are not writable by less-privileged local users. The final
367+ component is opened with O_NOFOLLOW to defeat symlink-swap TOCTOU races,
368+ but intermediate directory components are not re-validated — pointing the
369+ upload root at a world-writable tree (e.g. inside /tmp) would let other
370+ local accounts redirect uploads via directory-level symlink races.
371+
372+ Args:
373+ local_path: Path to the local file on the MCP server's filesystem.
374+ Must resolve to a regular file inside NEXTCLOUD_MCP_UPLOAD_ROOT.
375+ remote_path: Destination path in Nextcloud relative to the user's root.
376+ Example: "Photos/vacation.jpg"
377+ content_type: Optional MIME type for the upload request. If omitted,
378+ inferred from the remote_path extension; falls back to
379+ "application/octet-stream". Note: Nextcloud re-derives the stored
380+ MIME type from the filename, so this mainly controls the upload header.
381+
382+ Returns:
383+ Confirmation message with the uploaded byte count.
384+ """
385+ config = get_config ()
386+ resolved = _resolve_local_upload_path (local_path , config .upload_root )
387+ size = resolved .stat ().st_size
388+ resolved_ct = _resolve_content_type (remote_path , content_type )
389+ client = get_client ()
390+ await client .dav_put_stream (
391+ remote_path ,
392+ lambda : _stream_local_file (resolved ),
393+ content_type = resolved_ct ,
394+ )
395+ return f"File uploaded successfully: { remote_path } ({ size } bytes, { resolved_ct } )"
396+
397+
277398def _register_destructive_tools (mcp : FastMCP ) -> None :
278399 @mcp .tool (annotations = DESTRUCTIVE )
279400 @require_permission (PermissionLevel .DESTRUCTIVE )
@@ -314,3 +435,5 @@ def register(mcp: FastMCP) -> None:
314435 _register_read_tools (mcp )
315436 _register_write_tools (mcp )
316437 _register_destructive_tools (mcp )
438+ if get_config ().upload_root :
439+ _register_upload_from_path_tool (mcp )
0 commit comments