Skip to content

Commit 8ab8925

Browse files
authored
Add upload_file_from_path tool for streaming local-file uploads (#45)
1 parent aee9f3b commit 8ab8925

11 files changed

Lines changed: 835 additions & 9 deletions

File tree

PROGRESS.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
- [x] Tasks tools: list_task_lists, get_tasks, get_task, create_task, update_task, complete_task, delete_task (2026-04-08)
3939
- [x] Unified Search tools: list_search_providers, unified_search (2026-04-12)
4040
- [x] upload_file_binary tool: base64-encoded binary upload with MIME inference (2026-04-21)
41+
- [x] upload_file_from_path tool: stream a local file to Nextcloud. Off by default; enabled via NEXTCLOUD_MCP_UPLOAD_ROOT, restricted to files inside that root (symlinks resolved) (2026-04-21)
4142

4243
### In Progress
4344

@@ -63,7 +64,7 @@
6364

6465
| Module | Tools | Tests |
6566
|--------|-------|-------|
66-
| Files | 9 | 60 |
67+
| Files | 10 | 77 |
6768
| Users | 5 | 20 |
6869
| Notifications | 3 | 11 |
6970
| Talk | 8 | 48 |
@@ -84,11 +85,14 @@
8485
| Tasks | 7 | 48 |
8586
| Search | 2 | 17 |
8687
| User Permissions || 15 |
87-
| Server || 7 |
88+
| Server || 8 |
8889
| Permissions || 34 |
8990
| Errors || 16 |
9091
| Client || 29 |
91-
| Config || 17 |
92+
| Config || 24 |
9293
| State || 2 |
93-
| File Helpers || 11 |
94-
| **Total** | **98** | **711** |
94+
| File Helpers || 26 |
95+
| **Total** | **99** | **751** |
96+
97+
Files shows 10, but one (`upload_file_from_path`) is only registered when
98+
`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 98 tools.

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ nc-mcp-server
3232

3333
## 98 Tools Across 20 Nextcloud Apps
3434

35+
A 99th tool, `upload_file_from_path`, is registered only when the operator sets
36+
`NEXTCLOUD_MCP_UPLOAD_ROOT`. See [Files](#files) for details.
37+
3538
| Category | Tools | Protocol |
3639
|----------|-------|----------|
37-
| [Files](#files) | list, read, search, upload (text + binary), copy, move, delete | WebDAV |
40+
| [Files](#files) | list, read, search, upload (text / binary / from path), copy, move, delete | WebDAV |
3841
| [File Sharing](#file-sharing) | list, get, create, update, delete shares | OCS |
3942
| [Trashbin](#trashbin) | list, restore, delete item, empty trash | WebDAV |
4043
| [File Versions](#file-versions) | list, restore versions | WebDAV |
@@ -100,6 +103,9 @@ export NEXTCLOUD_PASSWORD=your-app-password # Use an app password, not your mai
100103
# Optional
101104
export NEXTCLOUD_MCP_PERMISSIONS=read # read (default), write, or destructive
102105
export NEXTCLOUD_MCP_RETRY_MAX=3 # max retries on 429/503 (default: 3, 0 to disable)
106+
export NEXTCLOUD_MCP_UPLOAD_ROOT= # unset (default). If set to an absolute directory,
107+
# enables upload_file_from_path, restricted to files
108+
# inside that directory (symlinks resolved).
103109
```
104110

105111
### Getting an App Password
@@ -167,11 +173,19 @@ nc-mcp-server
167173
| `search_files` | read | Search files by name, MIME type, or path pattern |
168174
| `upload_file` | write | Upload or overwrite a text file |
169175
| `upload_file_binary` | write | Upload or overwrite a binary file (images, PDFs, archives) from base64-encoded content |
176+
| `upload_file_from_path` | write | Stream a local file from the server's filesystem — only registered when `NEXTCLOUD_MCP_UPLOAD_ROOT` is set |
170177
| `create_directory` | write | Create a new directory |
171178
| `copy_file` | write | Copy a file or directory |
172179
| `move_file` | destructive | Move or rename a file |
173180
| `delete_file` | destructive | Delete a file or directory (moves to trash) |
174181

182+
`upload_file_from_path` is off by default because it gives the AI read access
183+
to the local filesystem. To enable it, set `NEXTCLOUD_MCP_UPLOAD_ROOT` to an
184+
absolute directory — only files resolving inside that directory (after symlink
185+
resolution) can be uploaded. This is the right choice when you need to upload
186+
multi-GB files that would blow past the size limit of an inline `base64` tool
187+
call; the body is streamed in chunks rather than loaded into memory.
188+
175189
### File Sharing
176190

177191
| Tool | Permission | Description |

src/nc_mcp_server/client.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
import contextlib
44
import logging
55
import xml.etree.ElementTree as ET
6+
from collections.abc import AsyncIterable, Callable
67
from typing import Any
78
from urllib.parse import quote as url_quote
89

910
import niquests
10-
from urllib3.util import Retry
11+
from urllib3.util import Retry, Timeout
1112

1213
from .config import Config
1314

@@ -306,6 +307,39 @@ async def dav_put(self, path: str, content: bytes, content_type: str = "applicat
306307
response = await self._do_request("PUT", url, data=content, headers={"Content-Type": content_type})
307308
_raise_for_status(response, f"Upload file '{path}'")
308309

310+
async def dav_put_stream(
311+
self,
312+
path: str,
313+
chunks_factory: Callable[[], AsyncIterable[bytes]],
314+
content_type: str = "application/octet-stream",
315+
) -> None:
316+
"""PUT (upload/overwrite) a file via WebDAV, streaming body from an async iterable.
317+
318+
Use this for files too large to hold fully in memory. niquests sends the body
319+
with Transfer-Encoding: chunked; we deliberately do not set Content-Length
320+
because nginx rejects (HTTP 400) requests that combine both headers.
321+
322+
The body is supplied as a factory (not a single iterable) because a cached
323+
session cookie can expire mid-run: if the first attempt drains the iterable
324+
and returns 401, the generic _do_request retry would send the retry with an
325+
empty body — Nextcloud would happily accept the empty PUT and silently
326+
truncate the file. Each attempt calls the factory to get a fresh generator.
327+
328+
The read timeout is disabled — a multi-GB upload can legitimately take
329+
minutes. Connect timeout still applies via the session default.
330+
"""
331+
user = self._config.user
332+
url = f"{self._base_url}/remote.php/dav/files/{user}/{path.lstrip('/')}"
333+
headers = {"Content-Type": content_type}
334+
timeout = Timeout(connect=30, read=None)
335+
336+
session = await self._get_session()
337+
response = await session.request("PUT", url, data=chunks_factory(), headers=headers, timeout=timeout)
338+
if await self._should_retry_auth(response):
339+
session = await self._get_session()
340+
response = await session.request("PUT", url, data=chunks_factory(), headers=headers, timeout=timeout)
341+
_raise_for_status(response, f"Upload file '{path}'")
342+
309343
async def dav_delete(self, path: str) -> None:
310344
"""DELETE a file or folder via WebDAV."""
311345
user = self._config.user

src/nc_mcp_server/config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
from dataclasses import dataclass, field
5+
from pathlib import Path
56

67
from .permissions import PermissionLevel
78

@@ -21,6 +22,9 @@ class Config:
2122
NEXTCLOUD_MCP_PORT: Port for HTTP server (default: 8100)
2223
NEXTCLOUD_MCP_RETRY_MAX: Max retries on 429/503 (default: 3, 0 to disable)
2324
NEXTCLOUD_MCP_APP_PASSWORD: Set to 'true' when using an app password to skip session caching
25+
NEXTCLOUD_MCP_UPLOAD_ROOT: Absolute path to a local directory. When set, enables the
26+
upload_file_from_path tool, restricted to files under this directory (symlinks
27+
are resolved before the containment check). Unset by default — tool disabled.
2428
"""
2529

2630
nextcloud_url: str = field(default="")
@@ -31,6 +35,7 @@ class Config:
3135
port: int = field(default=8100)
3236
retry_max: int = field(default=3)
3337
is_app_password: bool = field(default=False)
38+
upload_root: str = field(default="")
3439

3540
@classmethod
3641
def from_env(cls) -> "Config":
@@ -62,6 +67,17 @@ def from_env(cls) -> "Config":
6267
else:
6368
raise ValueError(f"Invalid NEXTCLOUD_MCP_APP_PASSWORD='{app_pw_raw}'. Expected: true/false, 1/0, yes/no.")
6469

70+
upload_root_raw = os.environ.get("NEXTCLOUD_MCP_UPLOAD_ROOT", "").strip()
71+
if upload_root_raw:
72+
root = Path(upload_root_raw).expanduser()
73+
if not root.exists():
74+
raise ValueError(f"NEXTCLOUD_MCP_UPLOAD_ROOT='{upload_root_raw}' does not exist.")
75+
if not root.is_dir():
76+
raise ValueError(f"NEXTCLOUD_MCP_UPLOAD_ROOT='{upload_root_raw}' is not a directory.")
77+
upload_root = str(root.resolve(strict=True))
78+
else:
79+
upload_root = ""
80+
6581
return cls(
6682
nextcloud_url=url,
6783
user=user,
@@ -71,6 +87,7 @@ def from_env(cls) -> "Config":
7187
port=port,
7288
retry_max=max(0, retry_max),
7389
is_app_password=is_app_password,
90+
upload_root=upload_root,
7491
)
7592

7693
def validate(self) -> None:

src/nc_mcp_server/tools/files.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""File management tools — list, read, upload, copy, delete, move, search files via WebDAV."""
22

3+
import asyncio
34
import base64
45
import binascii
6+
import errno
7+
import io
58
import json
69
import mimetypes
10+
import os
11+
from collections.abc import AsyncIterator
12+
from pathlib import Path
713
from xml.sax.saxutils import escape as xml_escape
814

915
from mcp.server.fastmcp import FastMCP
@@ -21,6 +27,7 @@
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

2633
def _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+
33106
def _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+
277398
def _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

Comments
 (0)