Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@

| Module | Tools | Tests |
|--------|-------|-------|
| Files | 10 | 166 |
| Files | 10 | 167 |
| Users | 7 | 72 |
| Groups | 4 | 15 |
| Notifications | 3 | 11 |
Expand All @@ -92,7 +92,7 @@
| Talk Threads | 5 | 78 |
| Talk Chat | 9 | 58 |
| Talk Settings | 6 | 41 |
| Talk Admin | 5 | 61 |
| Talk Admin | 7 | 73 |
| Talk Tags | 6 | 20 |
| Activity | 3 | 65 |
| Comments | 4 | 29 |
Expand All @@ -105,25 +105,25 @@
| Mail | 10 | 102 |
| Collectives | 25 | 106 |
| App Management | 4 | 14 |
| Calendar | 6 | 44 |
| Calendar | 13 | 98 |
| Contacts | 6 | 78 |
| Tasks | 7 | 51 |
| Search | 2 | 17 |
| User Permissions | — | 25 |
| Server | — | 8 |
| Permissions | — | 34 |
| Errors | — | 55 |
| Errors | — | 58 |
| Client | — | 75 |
| Config | — | 26 |
| State | — | 2 |
| File Helpers | — | 36 |
| File Helpers | — | 51 |
| File Reminders | 3 | 22 |
| Forms | 25 | 34 |
| Circles | 14 | 64 |
| Cospend | 16 | 41 |
| Flow | 5 | 58 |
| Pagination | — | 28 |
| **Total** | **223** | **1827** |
| **Total** | **232** | **1912** |

The test counts are what pytest collects, recomputed with `python scripts/sync_progress.py --write`,
which also fails when a test file is not assigned to a row.
Expand Down
35 changes: 29 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ export NEXTCLOUD_PASSWORD=your-app-password
nc-mcp-server
```

## 222 Tools Across 24 Nextcloud Apps
## 231 Tools Across 24 Nextcloud Apps

A 223rd tool, `upload_file_from_path`, is registered only when the operator sets
A 232nd tool, `upload_file_from_path`, is registered only when the operator sets
`NEXTCLOUD_MCP_UPLOAD_ROOT`. See [Files](#files) for details.

| Category | Tools | Protocol |
Expand All @@ -52,7 +52,7 @@ A 223rd tool, `upload_file_from_path`, is registered only when the operator sets
| [Talk](#talk) | conversations, messages, threads, participants, edits, reactions, read state, shared items, pins, reminders, personal settings and tags, participant and conversation management | OCS |
| [Talk Polls](#talk-polls) | get, create, vote, close polls | OCS |
| [Announcements](#announcements) | list, create, delete announcements | OCS |
| [Calendar](#calendar) | list calendars, CRUD events | CalDAV |
| [Calendar](#calendar) | list, create, share, publish and delete calendars; CRUD events with time zones and recurrence checks | CalDAV |
| [Contacts](#contacts) | list address books, CRUD contacts | CardDAV |
| [Tasks](#tasks) | list lists, CRUD tasks, complete | CalDAV |
| [Mail](#mail) | accounts, mailboxes, messages, send, move, flags, tags | OCS + REST |
Expand Down Expand Up @@ -314,7 +314,7 @@ level, as does disabling an account with `set_user_enabled`.
| Tool | Permission | Description |
|------|-----------|-------------|
| `list_conversations` | read | List all Talk conversations |
| `get_conversation` | read | Get conversation details |
| `get_conversation` | read | Get conversation details, with the link to join it |
| `get_messages` | read | Get messages from a conversation, or from one thread |
| `get_participants` | read | List participants in a conversation |
| `list_threads` | read | List the most recently active threads in a conversation |
Expand All @@ -328,7 +328,9 @@ level, as does disabling an account with `set_user_enabled`.
| `list_conversation_tags` | read | List your personal conversation tags |
| `list_conversation_presets` | read | List the presets create_conversation can start from |
| `send_message` | write | Send a message; can start a thread or post into one |
| `create_conversation` | write | Create a one-to-one, group or public conversation, optionally from a preset |
| `create_conversation` | write | Create a one-to-one, group or public conversation, optionally from a preset; returns the link to join it |
| `set_conversation_password` | write | Set the password guests need for a public conversation (moderators); removing it needs `destructive` |
| `set_conversation_lobby` | write | Turn the lobby on, optionally until a time, or off (moderators) |
| `update_conversation` | write | Rename, describe, lock (read-only) or open (public) a conversation (moderators); making it private needs `destructive`; owners can preserve it (Talk 25+) |
| `add_participant` | write | Add a user, group, team, email guest or federated user (moderators) |
| `set_participant_role` | write | Make a participant owner (Talk 25+), moderator or user |
Expand Down Expand Up @@ -384,9 +386,30 @@ instead of the placeholders Talk stores (`{mention-user1}`, `{file}`).
| `list_calendars` | read | List user's calendars |
| `get_events` | read | Get events from a calendar (with date filtering) |
| `get_event` | read | Get a single event by UID |
| `create_event` | write | Create a calendar event |
| `create_event` | write | Create a calendar event (time zone, checked RRULE, video link) |
| `update_event` | write | Update an event (partial updates supported) |
| `delete_event` | destructive | Delete a calendar event |
| `get_calendar_shares` | read | Who a calendar is shared with, public link (owner only) |
| `create_calendar` | write | Create a calendar, optionally shared with a group right away |
| `share_calendar` | write | Share a calendar with a user/group or change their rights |
| `unshare_calendar` | write | Stop sharing a calendar with a user/group |
| `publish_calendar` | write | Create a public read-only link |
| `unpublish_calendar` | write | Remove the public link |
| `delete_calendar` | destructive | Delete a calendar (`expected_name` guard); for a sharee: remove it from their account |

- Time zone: times without an offset are wall time in `timezone` (tool argument), else
`NEXTCLOUD_MCP_TIMEZONE` (IANA name, e.g. `Europe/Berlin`), else UTC. Zoned events get a `TZID` and a
`VTIMEZONE`, so weekly events stay at 19:00 across daylight saving changes. Moving only the start keeps the
duration.
- RRULE: only `DAILY`/`WEEKLY`/`MONTHLY`/`YEARLY`, known parts, `COUNT`/`INTERVAL` at most 1000, never `COUNT`
and `UNTIL` together; `UNTIL` is converted to UTC (or a date for all-day events).
- Video link: `conference_url` is stored as `CONFERENCE` (RFC 7986) and as `LOCATION` when empty (as Nextcloud
Calendar does for Talk rooms), else as a `Video call: <url>` line in the description.
- Creator guard: `create_event` marks events with `X-NC-MCP-CREATED-BY:<user id>`. `update_event` refuses events
without the caller's mark, or with attendees (Nextcloud would notify them), unless `allow_foreign=true`, so a
client can ask its user first. `update_event`/`delete_event` take `expected_summary` and refuse to act if the
title differs. Event output never lists attendees or organizers, only `has_attendees` and `created_by_me`.
- Shared calendars appear as `<uri>_shared_by_<owner>` for the people they are shared with.

### Contacts

Expand Down
7 changes: 6 additions & 1 deletion scripts/sync_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
"Mail": ["integration/test_mail.py", "test_mail_tools.py"],
"Collectives": ["integration/test_collectives.py", "test_collectives_pages.py", "test_collectives_sharing.py"],
"App Management": ["integration/test_app_management.py"],
"Calendar": ["integration/test_calendar.py"],
"Calendar": [
"integration/test_calendar.py",
"integration/test_calendar_admin.py",
"integration/test_calendar_guard.py",
"test_calendar_helpers.py",
],
"Contacts": ["integration/test_contacts.py"],
"Tasks": ["integration/test_tasks.py"],
"Search": ["integration/test_search.py"],
Expand Down
21 changes: 21 additions & 0 deletions src/nc_mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import contextlib
import logging
import re
import xml.etree.ElementTree as ET
from collections.abc import AsyncIterable, Callable
from typing import Any, cast
Expand Down Expand Up @@ -60,6 +61,8 @@ def _raise_for_ocs_status(response: niquests.Response, context: str = "") -> Non
prefix = f"{context}: " if context else ""
try:
ocs = response.json()["ocs"]
if ocs["meta"].get("statuscode") == _OCS_NO_ROUTE:
raise NextcloudError(f"{prefix}{_no_route_message(context)}", code)
ocs_message: str = (
ocs["meta"]["message"]
or _ocs_field_errors(ocs.get("data"))
Expand All @@ -76,6 +79,24 @@ def _raise_for_ocs_status(response: niquests.Response, context: str = "") -> Non
raise NextcloudError(f"{prefix}{detail}", code)


# OCS answers 998 when no route matches the path. For an app path that means the app does not
# serve this account: not installed, disabled, or limited to groups the user is not in. Its own
# message ("Invalid query, please check the syntax...") misleads; a missing object inside an app
# comes back with the app's own status instead.
_OCS_NO_ROUTE = 998
_APP_PATH = re.compile(r"\bapps/([A-Za-z0-9_-]+)/")


def _no_route_message(context: str) -> str:
match = _APP_PATH.search(context)
if match is None:
return "Not found. This Nextcloud has no such API endpoint."
return (
f"The '{match.group(1)}' app is not available for this Nextcloud account"
" (not installed, disabled, or limited to groups the account is not in)"
)


_CONFIRMATION_REQUIRED = "Password confirmation is required"
_CONFIRMATION_HINT = (
". Nextcloud does not count an app password as a confirmed password for this action; use the"
Expand Down
15 changes: 15 additions & 0 deletions src/nc_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from dataclasses import dataclass, field
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from .permissions import PermissionLevel

Expand All @@ -25,6 +26,8 @@ class Config:
NEXTCLOUD_MCP_UPLOAD_ROOT: Absolute path to a local directory. When set, enables the
upload_file_from_path tool, restricted to files under this directory (symlinks
are resolved before the containment check). Unset by default — tool disabled.
NEXTCLOUD_MCP_TIMEZONE: IANA time zone (e.g. Europe/Berlin) for calendar times given without
an offset. Events then carry a TZID and a VTIMEZONE. Unset: such times are UTC.
"""

nextcloud_url: str = field(default="")
Expand All @@ -36,6 +39,8 @@ class Config:
retry_max: int = field(default=3)
is_app_password: bool = field(default=False)
upload_root: str = field(default="")
# IANA zone for calendar times without an offset; "" = UTC (see NEXTCLOUD_MCP_TIMEZONE).
timezone: str = field(default="")

@classmethod
def from_env(cls) -> "Config":
Expand Down Expand Up @@ -67,6 +72,15 @@ def from_env(cls) -> "Config":
else:
raise ValueError(f"Invalid NEXTCLOUD_MCP_APP_PASSWORD='{app_pw_raw}'. Expected: true/false, 1/0, yes/no.")

timezone = os.environ.get("NEXTCLOUD_MCP_TIMEZONE", "").strip()
if timezone:
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError(
f"Invalid NEXTCLOUD_MCP_TIMEZONE='{timezone}'. Expected an IANA name like Europe/Berlin."
) from None

upload_root_raw = os.environ.get("NEXTCLOUD_MCP_UPLOAD_ROOT", "").strip()
if upload_root_raw:
root = Path(upload_root_raw).expanduser()
Expand All @@ -88,6 +102,7 @@ def from_env(cls) -> "Config":
retry_max=max(0, retry_max),
is_app_password=is_app_password,
upload_root=upload_root,
timezone=timezone,
)

def validate(self) -> None:
Expand Down
Loading