|
| 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