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
22 changes: 17 additions & 5 deletions .github/workflows/tests-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,20 @@ jobs:
--health-timeout 5s
--health-retries 30
--health-start-period 60s
# The Mail account sends through smtp4dev and reads from Dovecot. smtp4dev's own IMAP server has
# no MOVE, no folders and drops \Flagged and keywords, so it can't back the move/flag/tag tools.
smtp4dev:
image: rnwood/smtp4dev:latest
env:
ServerOptions__BasePath: /smtp4dev
ports:
- 9025:25
- 9143:143
- 9080:80
dovecot:
image: dovecot/dovecot:2.4.5
env:
USER_PASSWORD: test
ports:
- 9143:31143
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Expand Down Expand Up @@ -79,9 +85,12 @@ jobs:
$OCC "php occ app:install forms" || echo "forms already installed"
$OCC "php occ app:install cospend" || echo "cospend already installed"
$OCC "php occ app:enable circles" || echo "circles enable failed (may not be shipped)"
# Dovecot refuses plaintext logins, and its image ships a self-signed certificate for STARTTLS.
$OCC "php occ config:system:set app.mail.verify-tls-peer --value=false --type=boolean"
SMTP4DEV_IP=$(docker inspect ${{ job.services.smtp4dev.id }} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
echo "smtp4dev IP: $SMTP4DEV_IP"
$OCC "php occ mail:account:create admin 'Test Mail' test@localhost $SMTP4DEV_IP 143 none test test $SMTP4DEV_IP 25 none test test"
DOVECOT_IP=$(docker inspect ${{ job.services.dovecot.id }} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
echo "smtp4dev IP: $SMTP4DEV_IP, dovecot IP: $DOVECOT_IP"
$OCC "php occ mail:account:create admin 'Test Mail' test@localhost $DOVECOT_IP 31143 tls test test $SMTP4DEV_IP 25 none test test"
$OCC "php occ mail:account:sync 1"
APP_PASS=$($OCC "php occ user:auth-tokens:add admin" | grep -oP '[A-Za-z0-9]{72}' | head -1)
if [ -z "$APP_PASS" ]; then echo "::error::Failed to generate app password"; exit 1; fi
Expand All @@ -99,7 +108,10 @@ jobs:
NEXTCLOUD_MCP_APP_PASSWORD: "true"
SMTP4DEV_HOST: localhost
SMTP4DEV_HTTP_PORT: "9080"
SMTP4DEV_SMTP_PORT: "9025"
MAIL_IMAP_HOST: localhost
MAIL_IMAP_PORT: "9143"
MAIL_IMAP_USER: test
MAIL_IMAP_PASSWORD: test
MAIL_RECIPIENT: test@localhost
NC_CONTAINER: ${{ job.services.nextcloud.id }}
MAIL_ACCOUNT_ID: "1"
Expand Down
9 changes: 5 additions & 4 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
- [x] Forms tools: 25 tools covering forms, questions, options, shares, submissions CRUD + export (2026-04-23)
- [x] Circles (Teams) tools: 14 tools — list/CRUD circles, member add/remove/level, search, join/leave (2026-04-24)
- [x] Cospend tools: 16 tools — projects (7), members (4), bills (5) — shared expense tracking (2026-04-26)
- [x] Mail triage tools: move_mail_message, set_mail_message_flags, create_mail_tag, add_mail_message_tag, remove_mail_message_tag via the Mail app's JSON routes; tags in message output; Mail integration tests read from Dovecot (2026-09-15)

### In Progress

Expand Down Expand Up @@ -81,7 +82,7 @@
| Versions | 2 | 18 |
| Shares | 5 | 40 |
| System Tags | 6 | 22 |
| Mail | 5 | 29 |
| Mail | 10 | 70 |
| Collectives | 3 | 22 |
| App Management | 4 | 14 |
| Calendar | 6 | 44 |
Expand All @@ -92,15 +93,15 @@
| Server | — | 8 |
| Permissions | — | 34 |
| Errors | — | 16 |
| Client | — | 29 |
| Client | — | 45 |
| Config | — | 24 |
| State | — | 2 |
| File Helpers | — | 26 |
| File Reminders | 3 | 20 |
| Forms | 25 | 34 |
| Circles | 14 | 31 |
| Cospend | 16 | 35 |
| **Total** | **157** | **871** |
| **Total** | **162** | **928** |

Files shows 10, but one (`upload_file_from_path`) is only registered when
`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 156 tools.
`NEXTCLOUD_MCP_UPLOAD_ROOT` is configured. Default deployments expose 161 tools.
11 changes: 8 additions & 3 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
```

## 140 Tools Across 23 Nextcloud Apps
## 161 Tools Across 23 Nextcloud Apps

A 141st tool, `upload_file_from_path`, is registered only when the operator sets
A 162nd 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 @@ -54,7 +54,7 @@ A 141st tool, `upload_file_from_path`, is registered only when the operator sets
| [Calendar](#calendar) | list calendars, CRUD events | CalDAV |
| [Contacts](#contacts) | list address books, CRUD contacts | CardDAV |
| [Tasks](#tasks) | list lists, CRUD tasks, complete | CalDAV |
| [Mail](#mail) | accounts, mailboxes, messages, send | OCS |
| [Mail](#mail) | accounts, mailboxes, messages, send, move, flags, tags | OCS + REST |
| [Collectives](#collectives) | list, pages, create, trash, restore | OCS |
| [Forms](#forms) | CRUD forms, questions, options, shares, submissions + export | OCS |
| [Circles (Teams)](#circles-teams) | list, CRUD, members (add/remove/promote), join/leave, search | OCS |
Expand Down Expand Up @@ -354,6 +354,11 @@ call; the body is streamed in chunks rather than loaded into memory.
| `list_mail_messages` | read | List messages in a mailbox |
| `get_mail_message` | read | Get full message content |
| `send_mail` | write | Send an email |
| `move_mail_message` | write | Move a message to another mailbox (its ID changes) |
| `set_mail_message_flags` | write | Mark as read/unread, starred, answered |
| `create_mail_tag` | write | Create a tag, or get the existing one with the same label |
| `add_mail_message_tag` | write | Tag a message |
| `remove_mail_message_tag` | write | Remove a tag from a message |

### Collectives

Expand Down
60 changes: 59 additions & 1 deletion src/nc_mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import logging
import xml.etree.ElementTree as ET
from collections.abc import AsyncIterable, Callable
from typing import Any
from typing import Any, cast
from urllib.parse import quote as url_quote

import niquests
Expand Down Expand Up @@ -66,6 +66,36 @@ def _raise_for_ocs_status(response: niquests.Response, context: str = "") -> Non
raise NextcloudError(f"{prefix}{detail}", code)


def _app_error_message(body: object) -> str:
"""Return the error message from an app route's JSON body, or "" when it has none.

App routes outside OCS have no common error envelope. The Mail app, for example, answers
client errors with {"status": "fail", "data": {"message": ...}} and server errors with
{"status": "error", "message": ...}.
"""
if not isinstance(body, dict):
return ""
payload = cast(dict[str, Any], body)
data = payload.get("data")
message = cast(dict[str, Any], data).get("message") if isinstance(data, dict) else None
if message is None:
message = payload.get("message")
return message if isinstance(message, str) else ""


def _raise_for_app_status(response: niquests.Response, context: str = "") -> None:
"""Raise NextcloudError for a failed app route, preferring the message from its JSON body."""
if response.ok:
return
code = response.status_code or 0
prefix = f"{context}: " if context else ""
message = ""
with contextlib.suppress(ValueError, TypeError):
message = _app_error_message(response.json())
detail = message or _STATUS_MESSAGES.get(code, f"HTTP {code}")
raise NextcloudError(f"{prefix}{detail}", code)


# XML namespaces used in WebDAV responses
DAV_NS = "DAV:"
OC_NS = "http://owncloud.org/ns"
Expand Down Expand Up @@ -287,6 +317,34 @@ async def ocs_put_json(self, path: str, json_data: dict[str, Any] | None = None)
result: dict[str, Any] = response.json() # type: ignore[assignment]
return result["ocs"]["data"]

# --- App JSON routes ---

async def app_request_json(
self,
method: str,
path: str,
json_data: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
"""Call an app's non-OCS JSON route under /index.php/apps/ and return the decoded body.

Some app actions exist only as the routes an app's web UI uses (Mail's move, flag and tag
endpoints, for example). Those controllers require a CSRF token, and Nextcloud skips that
check for requests carrying ``OCS-APIRequest: true``, so the header is sent explicitly.
Responses are plain JSON without an OCS envelope; an empty body returns None.
"""
url = f"{self._base_url}/index.php/apps/{path}"
kwargs: dict[str, Any] = {"headers": {"OCS-APIRequest": "true", "Accept": "application/json"}}
if json_data is not None:
kwargs["json"] = json_data
if params:
kwargs["params"] = params
response = await self._do_request(method, url, **kwargs)
_raise_for_app_status(response, f"{method} apps/{path}")
if not (response.content or b"").strip():
return None
return response.json()

# --- WebDAV ---

async def dav_propfind(self, path: str, depth: int = 1) -> list[dict[str, Any]]:
Expand Down
Loading