Skip to content

Commit e876c65

Browse files
committed
Add Files Reminders tools with client-side date validation workaround for NC silent-no-op bug on PUT
1 parent 8ab8925 commit e876c65

6 files changed

Lines changed: 386 additions & 6 deletions

File tree

PROGRESS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,15 @@
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)
4141
- [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)
42+
- [x] File Reminders tools: get_file_reminder, set_file_reminder, remove_file_reminder (2026-04-22)
4243

4344
### In Progress
4445

4546
### Blocked
4647
(none)
4748

4849
### Next Up
49-
- Deck, Notes
50+
- Tables, Forms, Weather Status (all with full OCS coverage)
5051

5152
## Phases
5253

@@ -56,7 +57,7 @@
5657
| 2 | Communication (Talk, Announcements, Mail) | Complete |
5758
| 3 | Groupware (Calendar, Contacts, Tasks, Deck, Notes) | In Progress |
5859
| 4 | Collaboration (Collectives, Forms, Polls, Tables) | Not Started |
59-
| 5 | Storage & Search | In Progress |
60+
| 5 | Storage & Search (Files Reminders, Unified Search done) | In Progress |
6061
| 6 | Media & Data | Not Started |
6162
| 7 | Advanced & Admin (App Management, etc.) | In Progress |
6263

@@ -92,7 +93,8 @@
9293
| Config || 24 |
9394
| State || 2 |
9495
| File Helpers || 26 |
95-
| **Total** | **99** | **751** |
96+
| File Reminders | 3 | 20 |
97+
| **Total** | **102** | **771** |
9698

9799
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.
100+
`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 101 tools.

README.md

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

33-
## 98 Tools Across 20 Nextcloud Apps
33+
## 101 Tools Across 21 Nextcloud Apps
3434

35-
A 99th tool, `upload_file_from_path`, is registered only when the operator sets
35+
A 102nd tool, `upload_file_from_path`, is registered only when the operator sets
3636
`NEXTCLOUD_MCP_UPLOAD_ROOT`. See [Files](#files) for details.
3737

3838
| Category | Tools | Protocol |
@@ -42,6 +42,7 @@ A 99th tool, `upload_file_from_path`, is registered only when the operator sets
4242
| [Trashbin](#trashbin) | list, restore, delete item, empty trash | WebDAV |
4343
| [File Versions](#file-versions) | list, restore versions | WebDAV |
4444
| [File Comments](#file-comments) | list, add, edit, delete comments | WebDAV |
45+
| [File Reminders](#file-reminders) | get, set, remove per-file reminders | OCS |
4546
| [System Tags](#system-tags) | list, create, assign, unassign, delete tags | WebDAV |
4647
| [Users](#users) | get current, list, get, create, delete users | OCS |
4748
| [User Status](#user-status) | get, set, clear status | OCS |
@@ -212,6 +213,14 @@ call; the body is streamed in chunks rather than loaded into memory.
212213
| `list_versions` | read | List version history of a file |
213214
| `restore_version` | write | Restore a previous version of a file |
214215

216+
### File Reminders
217+
218+
| Tool | Permission | Description |
219+
|------|-----------|-------------|
220+
| `get_file_reminder` | read | Get the reminder set on a file (null if none) |
221+
| `set_file_reminder` | write | Set or replace a reminder due date (ISO 8601, must be in the future) |
222+
| `remove_file_reminder` | destructive | Remove the reminder from a file |
223+
215224
### File Comments
216225

217226
| Tool | Permission | Description |

src/nc_mcp_server/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
files,
1818
mail,
1919
notifications,
20+
reminders,
2021
search,
2122
shares,
2223
system_tags,
@@ -64,6 +65,7 @@ def create_server(config: Config | None = None) -> FastMCP:
6465
files.register(mcp)
6566
mail.register(mcp)
6667
notifications.register(mcp)
68+
reminders.register(mcp)
6769
search.register(mcp)
6870
shares.register(mcp)
6971
system_tags.register(mcp)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""File Reminders tools — per-file reminder notifications via OCS API."""
2+
3+
import json
4+
from datetime import UTC, datetime
5+
6+
from mcp.server.fastmcp import FastMCP
7+
8+
from ..annotations import ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY
9+
from ..client import NextcloudError
10+
from ..permissions import PermissionLevel, require_permission
11+
from ..state import get_client
12+
13+
14+
def _validate_due_date(due_date: str) -> None:
15+
"""Validate ISO 8601 + future-date before any API call.
16+
17+
Why: set_file_reminder works around a Nextcloud bug by calling DELETE
18+
before PUT. If the PUT then fails server-side validation, the original
19+
reminder is already gone. Pre-validating here keeps failed calls from
20+
destroying an existing reminder.
21+
"""
22+
try:
23+
parsed = datetime.fromisoformat(due_date)
24+
except ValueError as exc:
25+
raise NextcloudError(
26+
f"Invalid due_date '{due_date}': must be an ISO 8601 timestamp with "
27+
"timezone, e.g. '2026-05-01T10:00:00+00:00' or '2026-05-01T10:00:00Z'.",
28+
400,
29+
) from exc
30+
if parsed.tzinfo is None or parsed.utcoffset() is None:
31+
raise NextcloudError(
32+
f"Invalid due_date '{due_date}': timezone is required (e.g. '+00:00' or 'Z').",
33+
400,
34+
)
35+
if parsed <= datetime.now(UTC):
36+
raise NextcloudError(
37+
f"Invalid due_date '{due_date}': must be in the future.",
38+
400,
39+
)
40+
41+
42+
def _register_read_tools(mcp: FastMCP) -> None:
43+
@mcp.tool(annotations=READONLY)
44+
@require_permission(PermissionLevel.READ)
45+
async def get_file_reminder(file_id: int) -> str:
46+
"""Get the reminder set on a specific file.
47+
48+
Returns the due date as an ISO 8601 timestamp, or null if no reminder
49+
is set for the file. Also returns null when the file does not exist —
50+
the Nextcloud API does not distinguish the two cases, so this tool
51+
cannot be used to check file existence.
52+
53+
Args:
54+
file_id: Numeric Nextcloud file id. Get this from list_directory,
55+
search_files, or any tool that returns file metadata.
56+
57+
Returns:
58+
JSON object with file_id and due_date. due_date is null when no
59+
reminder is set.
60+
"""
61+
client = get_client()
62+
data = await client.ocs_get(f"apps/files_reminders/api/v1/{file_id}")
63+
return json.dumps({"file_id": file_id, "due_date": data.get("dueDate")})
64+
65+
66+
def _register_write_tools(mcp: FastMCP) -> None:
67+
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
68+
@require_permission(PermissionLevel.WRITE)
69+
async def set_file_reminder(file_id: int, due_date: str) -> str:
70+
"""Set or update the reminder on a file.
71+
72+
The due date must be an ISO 8601 timestamp including a timezone and
73+
must be in the future. Past timestamps are rejected. Setting a
74+
reminder on a file that already has one replaces the existing one.
75+
76+
Args:
77+
file_id: Numeric Nextcloud file id.
78+
due_date: ISO 8601 timestamp with timezone, e.g.
79+
"2026-05-01T10:00:00+00:00" or "2026-05-01T10:00:00Z".
80+
81+
Returns:
82+
JSON object with file_id and due_date confirming the value set.
83+
"""
84+
_validate_due_date(due_date)
85+
client = get_client()
86+
# Nextcloud's PUT endpoint silently no-ops when a reminder already exists
87+
# (RichReminder composition wrapper defeats the mapper's dirty tracking).
88+
# Delete first so the PUT always takes the INSERT path.
89+
try:
90+
await client.ocs_delete(f"apps/files_reminders/api/v1/{file_id}")
91+
except NextcloudError as e:
92+
if e.status_code != 404:
93+
raise
94+
try:
95+
await client.ocs_put(
96+
f"apps/files_reminders/api/v1/{file_id}",
97+
data={"dueDate": due_date},
98+
)
99+
except NextcloudError as e:
100+
if e.status_code == 404:
101+
raise NextcloudError(f"File with id {file_id} not found.", 404) from e
102+
if e.status_code == 400:
103+
raise NextcloudError(
104+
f"Nextcloud rejected due_date '{due_date}'. Must be ISO 8601 in the future.",
105+
400,
106+
) from e
107+
raise
108+
return json.dumps({"file_id": file_id, "due_date": due_date})
109+
110+
111+
def _register_destructive_tools(mcp: FastMCP) -> None:
112+
@mcp.tool(annotations=DESTRUCTIVE)
113+
@require_permission(PermissionLevel.DESTRUCTIVE)
114+
async def remove_file_reminder(file_id: int) -> str:
115+
"""Remove the reminder set on a file.
116+
117+
Fails with "No reminder is set" if the file has no active reminder.
118+
119+
Args:
120+
file_id: Numeric Nextcloud file id.
121+
122+
Returns:
123+
Confirmation message.
124+
"""
125+
client = get_client()
126+
try:
127+
await client.ocs_delete(f"apps/files_reminders/api/v1/{file_id}")
128+
except NextcloudError as e:
129+
if e.status_code == 404:
130+
raise NextcloudError(
131+
f"No reminder is set on file {file_id}, or the file does not exist.",
132+
404,
133+
) from e
134+
raise
135+
return f"Reminder removed from file {file_id}."
136+
137+
138+
def register(mcp: FastMCP) -> None:
139+
"""Register file reminder tools with the MCP server."""
140+
_register_read_tools(mcp)
141+
_register_write_tools(mcp)
142+
_register_destructive_tools(mcp)

0 commit comments

Comments
 (0)