Skip to content

Commit aee9f3b

Browse files
authored
Add upload_file_binary tool for base64-encoded binary uploads (#44)
1 parent f946fc6 commit aee9f3b

6 files changed

Lines changed: 289 additions & 5 deletions

File tree

PROGRESS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
- [x] Contacts tools: list_addressbooks, get_contacts, get_contact, create_contact, update_contact, delete_contact (2026-03-31)
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)
40+
- [x] upload_file_binary tool: base64-encoded binary upload with MIME inference (2026-04-21)
4041

4142
### In Progress
4243

@@ -62,7 +63,7 @@
6263

6364
| Module | Tools | Tests |
6465
|--------|-------|-------|
65-
| Files | 8 | 47 |
66+
| Files | 9 | 60 |
6667
| Users | 5 | 20 |
6768
| Notifications | 3 | 11 |
6869
| Talk | 8 | 48 |
@@ -89,4 +90,5 @@
8990
| Client || 29 |
9091
| Config || 17 |
9192
| State || 2 |
92-
| **Total** | **97** | **687** |
93+
| File Helpers || 11 |
94+
| **Total** | **98** | **711** |

README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,11 @@ export NEXTCLOUD_PASSWORD=your-app-password
3030
nc-mcp-server
3131
```
3232

33-
## 88 Tools Across 18 Nextcloud Apps
33+
## 98 Tools Across 20 Nextcloud Apps
3434

3535
| Category | Tools | Protocol |
3636
|----------|-------|----------|
37-
| [Files](#files) | list, read, search, upload, copy, move, delete | WebDAV |
37+
| [Files](#files) | list, read, search, upload (text + binary), copy, move, delete | WebDAV |
3838
| [File Sharing](#file-sharing) | list, get, create, update, delete shares | OCS |
3939
| [Trashbin](#trashbin) | list, restore, delete item, empty trash | WebDAV |
4040
| [File Versions](#file-versions) | list, restore versions | WebDAV |
@@ -49,8 +49,10 @@ nc-mcp-server
4949
| [Announcements](#announcements) | list, create, delete announcements | OCS |
5050
| [Calendar](#calendar) | list calendars, CRUD events | CalDAV |
5151
| [Contacts](#contacts) | list address books, CRUD contacts | CardDAV |
52+
| [Tasks](#tasks) | list lists, CRUD tasks, complete | CalDAV |
5253
| [Mail](#mail) | accounts, mailboxes, messages, send | REST |
5354
| [Collectives](#collectives) | list, pages, create, trash, restore | REST |
55+
| [Unified Search](#unified-search) | list providers, search across apps | OCS |
5456
| [App Management](#app-management) | list, info, enable, disable apps | OCS |
5557

5658
## Security: Permission Model
@@ -163,7 +165,8 @@ nc-mcp-server
163165
| `list_directory` | read | List files and folders in a directory |
164166
| `get_file` | read | Read a file's content (returns images as MCP ImageContent) |
165167
| `search_files` | read | Search files by name, MIME type, or path pattern |
166-
| `upload_file` | write | Upload or overwrite a file |
168+
| `upload_file` | write | Upload or overwrite a text file |
169+
| `upload_file_binary` | write | Upload or overwrite a binary file (images, PDFs, archives) from base64-encoded content |
167170
| `create_directory` | write | Create a new directory |
168171
| `copy_file` | write | Copy a file or directory |
169172
| `move_file` | destructive | Move or rename a file |
@@ -299,6 +302,18 @@ nc-mcp-server
299302
| `update_contact` | write | Update a contact (ETag concurrency control) |
300303
| `delete_contact` | destructive | Delete a contact |
301304

305+
### Tasks
306+
307+
| Tool | Permission | Description |
308+
|------|-----------|-------------|
309+
| `list_task_lists` | read | List task lists (CalDAV VTODO collections) |
310+
| `get_tasks` | read | List tasks in a list (with status/completed filters) |
311+
| `get_task` | read | Get a single task by UID |
312+
| `create_task` | write | Create a task (due date, priority, categories, etc.) |
313+
| `update_task` | write | Update a task (partial updates supported) |
314+
| `complete_task` | write | Mark a task as completed |
315+
| `delete_task` | destructive | Delete a task |
316+
302317
### Mail
303318

304319
| Tool | Permission | Description |
@@ -325,6 +340,13 @@ nc-mcp-server
325340
| `restore_collective` | write | Restore a collective from trash |
326341
| `restore_collective_page` | write | Restore a page from trash |
327342

343+
### Unified Search
344+
345+
| Tool | Permission | Description |
346+
|------|-----------|-------------|
347+
| `list_search_providers` | read | List available search providers (files, mail, talk, etc.) |
348+
| `unified_search` | read | Search across one or more providers with pagination |
349+
328350
### App Management
329351

330352
| Tool | Permission | Description |

src/nc_mcp_server/tools/files.py

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

33
import base64
4+
import binascii
45
import json
6+
import mimetypes
57
from xml.sax.saxutils import escape as xml_escape
68

79
from mcp.server.fastmcp import FastMCP
@@ -21,6 +23,13 @@
2123
_MAX_IMAGE_SIZE = 10 * 1024 * 1024
2224

2325

26+
def _resolve_content_type(path: str, content_type: str) -> str:
27+
if content_type.strip():
28+
return content_type.strip()
29+
guessed, _ = mimetypes.guess_type(path)
30+
return guessed or "application/octet-stream"
31+
32+
2433
def _build_search_xml(user: str, query: str, path: str, limit: int, offset: int, mimetype: str) -> str:
2534
"""Build a WebDAV SEARCH request body."""
2635
where_parts: list[str] = []
@@ -181,6 +190,9 @@ def _register_write_tools(mcp: FastMCP) -> None:
181190
async def upload_file(path: str, content: str) -> str:
182191
"""Upload or overwrite a text file in Nextcloud.
183192
193+
Use this for plain-text content (markdown, source code, CSV, JSON, etc.).
194+
For binary files (images, PDFs, archives), use upload_file_binary instead.
195+
184196
Creates the file if it doesn't exist. Overwrites if it does.
185197
186198
Args:
@@ -194,6 +206,39 @@ async def upload_file(path: str, content: str) -> str:
194206
await client.dav_put(path, content.encode("utf-8"), content_type="text/plain; charset=utf-8")
195207
return f"File uploaded successfully: {path}"
196208

209+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
210+
@require_permission(PermissionLevel.WRITE)
211+
async def upload_file_binary(path: str, content_base64: str, content_type: str = "") -> str:
212+
"""Upload or overwrite a binary file in Nextcloud.
213+
214+
Use this for images, PDFs, archives, or any non-text content. The content
215+
must be base64-encoded. For plain-text files, use upload_file instead.
216+
217+
Creates the file if it doesn't exist. Overwrites if it does.
218+
219+
Args:
220+
path: Destination path relative to user's root. Example: "Photos/photo.png"
221+
content_base64: File bytes encoded as a base64 string. May be empty to
222+
create an empty file.
223+
content_type: MIME type for the upload request (e.g. "image/png",
224+
"application/pdf"). If omitted, inferred from the path extension;
225+
falls back to "application/octet-stream". Note: Nextcloud re-derives
226+
the stored MIME type from the filename, so this mainly controls the
227+
HTTP upload header.
228+
229+
Returns:
230+
Confirmation message with the uploaded byte count.
231+
"""
232+
cleaned = "".join(content_base64.split()) if content_base64 else ""
233+
try:
234+
data = base64.b64decode(cleaned, validate=True) if cleaned else b""
235+
except (binascii.Error, ValueError) as exc:
236+
raise ValueError(f"content_base64 is not valid base64: {exc}") from exc
237+
resolved = _resolve_content_type(path, content_type)
238+
client = get_client()
239+
await client.dav_put(path, data, content_type=resolved)
240+
return f"File uploaded successfully: {path} ({len(data)} bytes, {resolved})"
241+
197242
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
198243
@require_permission(PermissionLevel.WRITE)
199244
async def copy_file(source: str, destination: str) -> str:

tests/integration/test_files.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,181 @@ async def test_upload_large_text(self, nc_mcp: McpTestHelper) -> None:
139139
assert len(result) == 100_000
140140

141141

142+
class TestUploadFileBinary:
143+
"""Binary upload round-trips through Nextcloud. Payloads are fetched back with
144+
raw WebDAV (bypassing `get_file`) so we can assert byte-for-byte equality.
145+
"""
146+
147+
@pytest.mark.asyncio
148+
async def test_upload_png_round_trip(self, nc_mcp: McpTestHelper) -> None:
149+
await nc_mcp.create_test_dir()
150+
await nc_mcp.call(
151+
"upload_file_binary",
152+
path=f"{TEST_BASE_DIR}/pixel.png",
153+
content_base64=_TINY_PNG_B64,
154+
content_type="image/png",
155+
)
156+
got, ct = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/pixel.png")
157+
assert got == _TINY_PNG
158+
assert ct == "image/png"
159+
160+
@pytest.mark.asyncio
161+
async def test_upload_pdf_round_trip(self, nc_mcp: McpTestHelper) -> None:
162+
await nc_mcp.create_test_dir()
163+
pdf_bytes = (
164+
b"%PDF-1.4\n"
165+
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
166+
b"2 0 obj<</Type/Pages/Count 0/Kids[]>>endobj\n"
167+
b"xref\n0 3\n0000000000 65535 f\n"
168+
b"0000000009 00000 n\n0000000052 00000 n\n"
169+
b"trailer<</Size 3/Root 1 0 R>>\nstartxref\n91\n%%EOF\n"
170+
)
171+
pdf_b64 = base64.b64encode(pdf_bytes).decode("ascii")
172+
await nc_mcp.call(
173+
"upload_file_binary",
174+
path=f"{TEST_BASE_DIR}/sample.pdf",
175+
content_base64=pdf_b64,
176+
)
177+
got, ct = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/sample.pdf")
178+
assert got == pdf_bytes
179+
assert ct == "application/pdf"
180+
181+
@pytest.mark.asyncio
182+
async def test_upload_full_byte_range(self, nc_mcp: McpTestHelper) -> None:
183+
"""Bytes 0x00-0xFF cover the full range that plain upload_file cannot send."""
184+
await nc_mcp.create_test_dir()
185+
raw = bytes(range(256)) * 8 # 2 KiB of every byte value
186+
b64 = base64.b64encode(raw).decode("ascii")
187+
await nc_mcp.call(
188+
"upload_file_binary",
189+
path=f"{TEST_BASE_DIR}/all-bytes.bin",
190+
content_base64=b64,
191+
content_type="application/octet-stream",
192+
)
193+
got, _ = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/all-bytes.bin")
194+
assert got == raw
195+
196+
@pytest.mark.asyncio
197+
async def test_content_type_inferred_from_extension(self, nc_mcp: McpTestHelper) -> None:
198+
await nc_mcp.create_test_dir()
199+
await nc_mcp.call(
200+
"upload_file_binary",
201+
path=f"{TEST_BASE_DIR}/auto.png",
202+
content_base64=_TINY_PNG_B64,
203+
)
204+
_, ct = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/auto.png")
205+
assert ct == "image/png"
206+
207+
@pytest.mark.asyncio
208+
async def test_overwrites_existing_file(self, nc_mcp: McpTestHelper) -> None:
209+
await nc_mcp.create_test_dir()
210+
first = base64.b64encode(b"first-version").decode("ascii")
211+
second = base64.b64encode(b"second-version-longer").decode("ascii")
212+
await nc_mcp.call("upload_file_binary", path=f"{TEST_BASE_DIR}/ow.bin", content_base64=first)
213+
await nc_mcp.call("upload_file_binary", path=f"{TEST_BASE_DIR}/ow.bin", content_base64=second)
214+
got, _ = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/ow.bin")
215+
assert got == b"second-version-longer"
216+
217+
@pytest.mark.asyncio
218+
async def test_empty_content_creates_empty_file(self, nc_mcp: McpTestHelper) -> None:
219+
await nc_mcp.create_test_dir()
220+
await nc_mcp.call(
221+
"upload_file_binary",
222+
path=f"{TEST_BASE_DIR}/empty.bin",
223+
content_base64="",
224+
content_type="application/octet-stream",
225+
)
226+
got, _ = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/empty.bin")
227+
assert got == b""
228+
229+
@pytest.mark.asyncio
230+
async def test_result_message_reports_bytes_and_type(self, nc_mcp: McpTestHelper) -> None:
231+
await nc_mcp.create_test_dir()
232+
result = await nc_mcp.call(
233+
"upload_file_binary",
234+
path=f"{TEST_BASE_DIR}/reported.png",
235+
content_base64=_TINY_PNG_B64,
236+
content_type="image/png",
237+
)
238+
assert str(len(_TINY_PNG)) in result
239+
assert "image/png" in result
240+
241+
@pytest.mark.asyncio
242+
async def test_invalid_base64_raises(self, nc_mcp: McpTestHelper) -> None:
243+
await nc_mcp.create_test_dir()
244+
with pytest.raises(ToolError, match=r"not valid base64"):
245+
await nc_mcp.call(
246+
"upload_file_binary",
247+
path=f"{TEST_BASE_DIR}/bad.bin",
248+
content_base64="!!!not-base64!!!",
249+
)
250+
251+
@pytest.mark.asyncio
252+
async def test_mime_wrapped_base64_accepted(self, nc_mcp: McpTestHelper) -> None:
253+
"""MIME-style base64 (76-char line wraps with \\r\\n) should decode cleanly."""
254+
await nc_mcp.create_test_dir()
255+
raw = bytes(range(200))
256+
wrapped = base64.encodebytes(raw).decode("ascii") # always adds \n every 76 chars
257+
assert "\n" in wrapped
258+
await nc_mcp.call(
259+
"upload_file_binary",
260+
path=f"{TEST_BASE_DIR}/wrapped.bin",
261+
content_base64=wrapped,
262+
)
263+
got, _ = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/wrapped.bin")
264+
assert got == raw
265+
266+
@pytest.mark.asyncio
267+
async def test_base64_with_stray_whitespace_accepted(self, nc_mcp: McpTestHelper) -> None:
268+
await nc_mcp.create_test_dir()
269+
raw = b"payload with stray whitespace"
270+
encoded = base64.b64encode(raw).decode("ascii")
271+
dirty = f" {encoded[:10]} \t \n{encoded[10:]}\r\n"
272+
await nc_mcp.call(
273+
"upload_file_binary",
274+
path=f"{TEST_BASE_DIR}/dirty.bin",
275+
content_base64=dirty,
276+
)
277+
got, _ = await nc_mcp.client.dav_get(f"{TEST_BASE_DIR}/dirty.bin")
278+
assert got == raw
279+
280+
@pytest.mark.asyncio
281+
async def test_uploaded_image_readable_via_get_file(self, nc_mcp: McpTestHelper) -> None:
282+
"""Binary upload integrates with existing get_file image handling."""
283+
await nc_mcp.create_test_dir()
284+
await nc_mcp.call(
285+
"upload_file_binary",
286+
path=f"{TEST_BASE_DIR}/readable.png",
287+
content_base64=_TINY_PNG_B64,
288+
)
289+
result = await nc_mcp.mcp._tool_manager.call_tool("get_file", {"path": f"{TEST_BASE_DIR}/readable.png"})
290+
assert isinstance(result, list)
291+
item = result[0] # type: ignore[index]
292+
assert item.type == "image" # type: ignore[union-attr]
293+
assert item.mimeType == "image/png" # type: ignore[union-attr]
294+
assert base64.b64decode(item.data) == _TINY_PNG # type: ignore[union-attr]
295+
296+
@pytest.mark.asyncio
297+
async def test_read_only_blocks(self, nc_mcp_read_only: McpTestHelper) -> None:
298+
with pytest.raises(ToolError, match=r"[Pp]ermission"):
299+
await nc_mcp_read_only.call(
300+
"upload_file_binary",
301+
path=f"{TEST_BASE_DIR}/denied.png",
302+
content_base64=_TINY_PNG_B64,
303+
)
304+
305+
@pytest.mark.asyncio
306+
async def test_write_permission_allows(self, nc_mcp_write: McpTestHelper) -> None:
307+
await nc_mcp_write.create_test_dir()
308+
result = await nc_mcp_write.call(
309+
"upload_file_binary",
310+
path=f"{TEST_BASE_DIR}/write-ok.png",
311+
content_base64=_TINY_PNG_B64,
312+
content_type="image/png",
313+
)
314+
assert "uploaded successfully" in result
315+
316+
142317
class TestCreateDirectory:
143318
@pytest.mark.asyncio
144319
async def test_create_directory(self, nc_mcp: McpTestHelper) -> None:

tests/integration/test_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"update_share",
106106
"update_task",
107107
"upload_file",
108+
"upload_file_binary",
108109
"vote_poll",
109110
]
110111

tests/test_files_helpers.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Unit tests for pure helpers in tools.files."""
2+
3+
from nc_mcp_server.tools.files import _resolve_content_type
4+
5+
6+
class TestResolveContentType:
7+
def test_explicit_content_type_kept(self) -> None:
8+
assert _resolve_content_type("photo.png", "custom/type") == "custom/type"
9+
10+
def test_explicit_content_type_kept_for_unknown_extension(self) -> None:
11+
assert _resolve_content_type("blob.weirdext", "application/x-custom") == "application/x-custom"
12+
13+
def test_png_extension_inferred(self) -> None:
14+
assert _resolve_content_type("photo.png", "") == "image/png"
15+
16+
def test_pdf_extension_inferred(self) -> None:
17+
assert _resolve_content_type("doc.pdf", "") == "application/pdf"
18+
19+
def test_jpeg_extension_inferred(self) -> None:
20+
assert _resolve_content_type("photo.jpeg", "") == "image/jpeg"
21+
22+
def test_unknown_extension_falls_back(self) -> None:
23+
assert _resolve_content_type("blob.weirdext", "") == "application/octet-stream"
24+
25+
def test_no_extension_falls_back(self) -> None:
26+
assert _resolve_content_type("noext", "") == "application/octet-stream"
27+
28+
def test_nested_path_inference(self) -> None:
29+
assert _resolve_content_type("deep/sub/path/photo.png", "") == "image/png"
30+
31+
def test_whitespace_only_content_type_falls_back_to_inference(self) -> None:
32+
assert _resolve_content_type("photo.png", " ") == "image/png"
33+
assert _resolve_content_type("photo.png", "\t\n") == "image/png"
34+
35+
def test_whitespace_only_content_type_falls_back_to_octet_stream(self) -> None:
36+
assert _resolve_content_type("blob.weirdext", " ") == "application/octet-stream"
37+
38+
def test_explicit_content_type_trimmed(self) -> None:
39+
assert _resolve_content_type("photo.png", " image/png ") == "image/png"

0 commit comments

Comments
 (0)