Skip to content

Commit 754fa38

Browse files
authored
Add Files Versions tools (#23)
1 parent fe4ce2c commit 754fa38

6 files changed

Lines changed: 348 additions & 2 deletions

File tree

PROGRESS.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,15 @@
2828
- [x] OCS error message extraction: surface Nextcloud error messages instead of generic HTTP codes (2026-03-27)
2929
- [x] Announcement Center tools: list_announcements, create_announcement, delete_announcement (2026-03-27)
3030
- [x] Files Trashbin tools: list_trash, restore_trash_item, empty_trash (2026-03-27)
31+
- [x] Files Versions tools: list_versions, restore_version (2026-03-27)
3132

3233
### In Progress
3334

3435
### Blocked
3536
(none)
3637

3738
### Next Up
38-
- Files Versions
39+
- Phase 3 — Groupware (Calendar, Contacts, Tasks, Deck, Notes, Mail)
3940

4041
## Phases
4142

@@ -60,6 +61,7 @@
6061
| User Status | 3 | 19 |
6162
| Announcements | 3 | 29 |
6263
| Trashbin | 3 | 22 |
64+
| Versions | 2 | 18 |
6365
| Shares | 5 | 40 |
6466
| System Tags | 6 | 22 |
6567
| Server || 7 |
@@ -68,4 +70,4 @@
6870
| Client || 29 |
6971
| Config || 17 |
7072
| State || 2 |
71-
| **Total** | **53** | **444** |
73+
| **Total** | **55** | **462** |

src/nextcloud_mcp/client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,37 @@ async def trashbin_restore(self, trash_path: str) -> None:
282282
response = await session.request("MOVE", src, headers={"Destination": dest})
283283
_raise_for_status(response, f"Restore '{trash_path}'")
284284

285+
async def versions_propfind(self, file_id: int) -> str:
286+
"""PROPFIND on the versions collection for a file. Returns raw XML text."""
287+
session = await self._get_session()
288+
user = self._config.user
289+
url = f"{self._base_url}/remote.php/dav/versions/{user}/versions/{file_id}/"
290+
body = (
291+
'<?xml version="1.0"?>'
292+
'<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">'
293+
"<d:prop>"
294+
"<d:getlastmodified/><d:getcontentlength/><d:getcontenttype/>"
295+
"<nc:version-author/><nc:version-label/>"
296+
"</d:prop></d:propfind>"
297+
)
298+
response = await session.request(
299+
"PROPFIND",
300+
url,
301+
data=body,
302+
headers={"Depth": "1", "Content-Type": "application/xml; charset=utf-8"},
303+
)
304+
_raise_for_status(response, f"List versions for file {file_id}")
305+
return response.text or ""
306+
307+
async def versions_restore(self, file_id: int, version_id: str) -> None:
308+
"""Restore a file version by MOVEing it to the restore folder."""
309+
session = await self._get_session()
310+
user = self._config.user
311+
src = f"{self._base_url}/remote.php/dav/versions/{user}/versions/{file_id}/{version_id}"
312+
dest = f"{self._base_url}/remote.php/dav/versions/{user}/restore/target"
313+
response = await session.request("MOVE", src, headers={"Destination": dest})
314+
_raise_for_status(response, f"Restore version '{version_id}' of file {file_id}")
315+
285316
async def trashbin_delete(self, trash_path: str = "") -> None:
286317
"""Delete a single item or empty the entire trash (if path is empty)."""
287318
session = await self._get_session()

src/nextcloud_mcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
trashbin,
1919
user_status,
2020
users,
21+
versions,
2122
)
2223

2324
__all__ = ["create_server", "get_client", "get_config"]
@@ -56,6 +57,7 @@ def create_server(config: Config | None = None) -> FastMCP:
5657
talk.register(mcp)
5758
trashbin.register(mcp)
5859
user_status.register(mcp)
60+
versions.register(mcp)
5961
users.register(mcp)
6062

6163
return mcp
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Files Versions tools — list and restore file versions via WebDAV API."""
2+
3+
import contextlib
4+
import json
5+
import xml.etree.ElementTree as ET
6+
from typing import Any
7+
from urllib.parse import unquote as url_unquote
8+
9+
from mcp.server.fastmcp import FastMCP
10+
11+
from ..annotations import ADDITIVE_IDEMPOTENT, READONLY
12+
from ..client import DAV_NS, NC_NS
13+
from ..permissions import PermissionLevel, require_permission
14+
from ..state import get_client, get_config
15+
16+
_VERSION_PROPS = [
17+
(f"{{{DAV_NS}}}getlastmodified", "last_modified"),
18+
(f"{{{DAV_NS}}}getcontentlength", "size"),
19+
(f"{{{DAV_NS}}}getcontenttype", "content_type"),
20+
(f"{{{NC_NS}}}version-author", "author"),
21+
(f"{{{NC_NS}}}version-label", "label"),
22+
]
23+
24+
25+
def _parse_versions_xml(xml_text: str, user: str, file_id: int) -> list[dict[str, Any]]:
26+
"""Parse a versions PROPFIND response into a list of version dicts."""
27+
root = ET.fromstring(xml_text) # noqa: S314
28+
entries: list[dict[str, Any]] = []
29+
prefix = f"/remote.php/dav/versions/{user}/versions/{file_id}/"
30+
31+
for response in root.findall(f"{{{DAV_NS}}}response"):
32+
href_el = response.find(f"{{{DAV_NS}}}href")
33+
if href_el is None or href_el.text is None:
34+
continue
35+
href = url_unquote(href_el.text)
36+
if href.rstrip("/") == prefix.rstrip("/"):
37+
continue
38+
version_id = href.split(prefix, 1)[1].rstrip("/") if prefix in href else ""
39+
if not version_id:
40+
continue
41+
propstat = response.find(f"{{{DAV_NS}}}propstat")
42+
if propstat is None:
43+
continue
44+
prop = propstat.find(f"{{{DAV_NS}}}prop")
45+
if prop is None:
46+
continue
47+
entry: dict[str, Any] = {"version_id": version_id}
48+
for tag, key in _VERSION_PROPS:
49+
el = prop.find(tag)
50+
if el is not None and el.text:
51+
entry[key] = el.text
52+
if "size" in entry:
53+
with contextlib.suppress(ValueError, TypeError):
54+
entry["size"] = int(entry["size"])
55+
entries.append(entry)
56+
57+
return entries
58+
59+
60+
def _register_read_tools(mcp: FastMCP) -> None:
61+
@mcp.tool(annotations=READONLY)
62+
@require_permission(PermissionLevel.READ)
63+
async def list_versions(file_id: int) -> str:
64+
"""List all versions of a file by its file ID.
65+
66+
Returns the version history including the current version.
67+
Use the file_id from list_directory or search_files results.
68+
69+
Args:
70+
file_id: The numeric Nextcloud file ID.
71+
72+
Returns:
73+
JSON list of versions, each with: version_id (unix timestamp),
74+
last_modified, size, content_type, author, and optionally label.
75+
Use version_id with restore_version to revert the file.
76+
"""
77+
client = get_client()
78+
xml_text = await client.versions_propfind(file_id)
79+
entries = _parse_versions_xml(xml_text, get_config().user, file_id)
80+
return json.dumps(entries, indent=2, default=str)
81+
82+
83+
def _register_write_tools(mcp: FastMCP) -> None:
84+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
85+
@require_permission(PermissionLevel.WRITE)
86+
async def restore_version(file_id: int, version_id: str) -> str:
87+
"""Restore a file to a previous version.
88+
89+
The file's current content is replaced with the content from the
90+
specified version. The pre-restore content is preserved as a new
91+
version in the history, so no data is lost.
92+
93+
Args:
94+
file_id: The numeric Nextcloud file ID.
95+
version_id: The version identifier from list_versions
96+
(a unix timestamp string, e.g. "1711000000").
97+
98+
Returns:
99+
Confirmation message.
100+
"""
101+
client = get_client()
102+
await client.versions_restore(file_id, version_id)
103+
return f"Restored file {file_id} to version {version_id}."
104+
105+
106+
def register(mcp: FastMCP) -> None:
107+
"""Register file version tools with the MCP server."""
108+
_register_read_tools(mcp)
109+
_register_write_tools(mcp)

tests/integration/test_server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,10 @@
5353
"list_tags",
5454
"list_trash",
5555
"list_users",
56+
"list_versions",
5657
"move_file",
5758
"restore_trash_item",
59+
"restore_version",
5860
"search_files",
5961
"send_message",
6062
"set_user_status",

0 commit comments

Comments
 (0)