Skip to content
Merged
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: 13 additions & 9 deletions .github/workflows/tests-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ jobs:
strategy:
fail-fast: false
matrix:
# NC34 GA 2026-06-09: replace "32" with "34" here and in the two jobs below once nextcloud:34 publishes.
nextcloud-version: ["32", "33"]
# NC35 GA 2026-09-16: add "35" here and in the two jobs below once nextcloud:35 is on Docker Hub,
# and raise after_n_builds in codecov.yml from 6 to 9.
nextcloud-version: ["34"]
services:
nextcloud:
image: nextcloud:${{ matrix.nextcloud-version }}
Expand Down Expand Up @@ -64,16 +65,19 @@ jobs:
run: |
NC_CONTAINER=${{ job.services.nextcloud.id }}
OCC="docker exec $NC_CONTAINER su -s /bin/bash www-data -c"
$OCC "php occ config:system:set ratelimit_protection_enabled --value=false --type=boolean"
$OCC "php occ config:system:set ratelimit.protection.enabled --value=false --type=boolean"
$OCC "php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean"
$OCC "php occ config:system:set loglevel --value=2 --type=integer"
$OCC "php occ config:system:set ratelimit_overwrite files_sharing.shareapi.createshare user limit --value=1000 --type=integer"
$OCC "php occ config:system:set ratelimit_overwrite files_sharing.shareapi.createshare user period --value=60 --type=integer"
# An app-password session never counts as password-confirmed, so user create/delete and app
# enable/disable only pass for exempted IPs. Test requests reach the container from the network gateway.
NC_GATEWAY=$(docker inspect $NC_CONTAINER --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}')
$OCC "php occ config:system:set allowed_no_password_confirmation_ranges 0 --value=$NC_GATEWAY/32"
$OCC "php occ app:install spreed" || echo "spreed already installed"
$OCC "php occ app:install announcementcenter" || echo "announcementcenter already installed"
$OCC "php occ app:install collectives" || echo "collectives already installed"
$OCC "php occ app:install mail"
$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)"
SMTP4DEV_IP=$(docker inspect ${{ job.services.smtp4dev.id }} --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
echo "smtp4dev IP: $SMTP4DEV_IP"
Expand Down Expand Up @@ -120,7 +124,7 @@ jobs:
strategy:
fail-fast: false
matrix:
nextcloud-version: ["32", "33"]
nextcloud-version: ["34"]
services:
nextcloud:
image: nextcloud:${{ matrix.nextcloud-version }}
Expand Down Expand Up @@ -161,7 +165,7 @@ jobs:
- name: Configure Nextcloud for testing
run: |
NC_CONTAINER=${{ job.services.nextcloud.id }}
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set ratelimit_protection_enabled --value=false --type=boolean"
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set ratelimit.protection.enabled --value=false --type=boolean"
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean"

- name: Run session cache tests (regular password)
Expand Down Expand Up @@ -191,7 +195,7 @@ jobs:
strategy:
fail-fast: false
matrix:
nextcloud-version: ["32", "33"]
nextcloud-version: ["34"]
services:
nextcloud:
image: nextcloud:${{ matrix.nextcloud-version }}
Expand Down Expand Up @@ -232,7 +236,7 @@ jobs:
- name: Configure Nextcloud for testing
run: |
NC_CONTAINER=${{ job.services.nextcloud.id }}
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set ratelimit_protection_enabled --value=false --type=boolean"
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set ratelimit.protection.enabled --value=false --type=boolean"
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean"
docker exec $NC_CONTAINER su -s /bin/bash www-data -c "php occ app:install spreed" || echo "spreed already installed"

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Integration Tests](https://github.com/cloud-py-api/nc_mcp_server/actions/workflows/tests-integration.yml/badge.svg)](https://github.com/cloud-py-api/nc_mcp_server/actions/workflows/tests-integration.yml)
[![codecov](https://codecov.io/gh/cloud-py-api/nc_mcp_server/graph/badge.svg)](https://codecov.io/gh/cloud-py-api/nc_mcp_server)

![NextcloudVersion](https://img.shields.io/badge/Nextcloud-32%20%7C%2033-blue)
![NextcloudVersion](https://img.shields.io/badge/Nextcloud-34%20%7C%2035-blue)
![PythonVersion](https://img.shields.io/badge/python-3.12%20%7C%203.13%20%7C%203.14-blue)
[![Python](https://img.shields.io/pypi/implementation/nc-mcp-server)](https://pypi.org/project/nc-mcp-server/)
[![PyPI](https://img.shields.io/pypi/v/nc-mcp-server.svg)](https://pypi.org/project/nc-mcp-server/)
Expand Down Expand Up @@ -119,6 +119,11 @@ export NEXTCLOUD_MCP_UPLOAD_ROOT= # unset (default). If set to an absolute
3. Under "Devices & sessions", create a new app password
4. Use this password for `NEXTCLOUD_PASSWORD`

Since Nextcloud 34.0.1 an app-password session never counts as password-confirmed, so the admin tools Nextcloud
guards with password confirmation (`create_user`, `delete_user`, `enable_app`, `disable_app`) fail with
"Password confirmation is required". To use them, allow the MCP server's IP address in `config.php`
(Nextcloud 34.0.3 and newer), e.g. `'allowed_no_password_confirmation_ranges' => ['192.0.2.10/32']`.

## Usage

### With Claude Desktop
Expand Down Expand Up @@ -483,7 +488,7 @@ export NEXTCLOUD_PASSWORD=admin
pytest tests/integration/ -v
```

CI automatically runs integration tests against the two newest released Nextcloud versions — currently 32 and 33 — using the official Docker images. Nextcloud 34 (GA 2026-06-09) joins the matrix once its image is published on Docker Hub.
CI runs the integration tests against Nextcloud 34 using the official Docker image. Nextcloud 35 (GA 2026-09-16) joins the matrix once `nextcloud:35` is published on Docker Hub.

## About This Project

Expand Down
4 changes: 2 additions & 2 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
codecov:
notify:
after_n_builds: 9
after_n_builds: 6

comment:
after_n_builds: 9
after_n_builds: 6
require_changes: true

coverage:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ lint.select = [
lint.extend-ignore = [
"I001",
"PLR0913",
"PLR0917",
"PLR2004",
"PLW0603",
"S311",
Expand Down
14 changes: 6 additions & 8 deletions src/nc_mcp_server/tools/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from icalendar import Calendar as ICal
from icalendar import Event as IEvent
from icalendar import vRecur
from mcp.server.fastmcp import FastMCP

from ..annotations import ADDITIVE, ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY
Expand Down Expand Up @@ -194,14 +195,11 @@ def _format_event(ical_text: str) -> dict[str, Any]:
"status": str(component.get("STATUS", "")),
"all_day": _is_all_day(component.get("DTSTART")),
}
if component.get("RRULE"):
result["rrule"] = component["RRULE"].to_ical().decode()
if component.get("CATEGORIES"):
cats = component["CATEGORIES"]
if isinstance(cats, list):
result["categories"] = [str(c) for group in cats for c in group.cats]
else:
result["categories"] = [str(c) for c in cats.cats]
rrule = component.get("RRULE")
if isinstance(rrule, vRecur) and rrule:
result["rrule"] = rrule.to_ical().decode()
if isinstance(component, IEvent) and component.categories:
result["categories"] = [str(c) for c in component.categories]
return result
msg = "No VEVENT found in calendar data"
raise ValueError(msg)
Expand Down
8 changes: 2 additions & 6 deletions src/nc_mcp_server/tools/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,8 @@ def _format_task(ical_text: str) -> dict[str, Any]:
"due": _dt_to_str(component.get("DUE")),
"completed": _dt_to_str(component.get("COMPLETED")),
}
if component.get("CATEGORIES"):
cats = component["CATEGORIES"]
if isinstance(cats, list):
result["categories"] = [str(c) for group in cats for c in group.cats]
else:
result["categories"] = [str(c) for c in cats.cats]
if isinstance(component, ITodo) and component.categories:
result["categories"] = [str(c) for c in component.categories]
return result
msg = "No VTODO found in calendar data"
raise ValueError(msg)
Expand Down
18 changes: 12 additions & 6 deletions tests/integration/test_circles.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,18 @@ async def test_promote_to_owner_transfers_and_demotes_caller(self, nc_mcp: McpTe
"""Promoting a member to owner transfers ownership; previous owner becomes admin (level=8)."""
circle = await _make_circle(nc_mcp, "mcp-test-circle-xfer-owner")
added = json.loads(await nc_mcp.call("add_circle_member", circle_id=circle["id"], user_id=circle_peer))
await nc_mcp.call(
"update_circle_member_level",
circle_id=circle["id"],
member_id=added["id"],
level="owner",
)
try:
await nc_mcp.call(
"update_circle_member_level",
circle_id=circle["id"],
member_id=added["id"],
level="owner",
)
except ToolError as e:
if "FOR UPDATE" not in str(e):
raise
# circles locks member rows with SELECT ... FOR UPDATE, which Nextcloud's SQLite platform rejects.
pytest.skip("circles cannot transfer ownership on SQLite")
members: list[dict[str, Any]] = json.loads(await nc_mcp.call("list_circle_members", circle_id=circle["id"]))
peer_level = next(m["level"] for m in members if m.get("userId") == circle_peer)
caller_level = next(m["level"] for m in members if m.get("userId") == get_config().user)
Expand Down
12 changes: 6 additions & 6 deletions tests/integration/test_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ class TestRestoreVersion:
async def test_restore_reverts_content(self, nc_mcp: McpTestHelper) -> None:
file_id = await _create_versioned_file(nc_mcp, "restore")
versions = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
oldest = sorted(versions, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions, key=lambda v: int(v["version_id"]))
await nc_mcp.call("restore_version", file_id=file_id, version_id=oldest["version_id"])
path = f"{TEST_BASE_DIR}/{VER_PREFIX}-restore.txt"
content = await nc_mcp.call("get_file", path=path)
Expand All @@ -139,7 +139,7 @@ async def test_restore_reverts_content(self, nc_mcp: McpTestHelper) -> None:
async def test_restore_returns_confirmation(self, nc_mcp: McpTestHelper) -> None:
file_id = await _create_versioned_file(nc_mcp, "confirm")
versions = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
oldest = sorted(versions, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions, key=lambda v: int(v["version_id"]))
result = await nc_mcp.call("restore_version", file_id=file_id, version_id=oldest["version_id"])
assert "Restored" in result
assert str(file_id) in result
Expand All @@ -149,7 +149,7 @@ async def test_restore_preserves_version_history(self, nc_mcp: McpTestHelper) ->
file_id = await _create_versioned_file(nc_mcp, "history")
versions_before = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
count_before = len(versions_before)
oldest = sorted(versions_before, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions_before, key=lambda v: int(v["version_id"]))
await nc_mcp.call("restore_version", file_id=file_id, version_id=oldest["version_id"])
versions_after = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
assert len(versions_after) >= count_before
Expand All @@ -164,7 +164,7 @@ async def test_restore_nonexistent_version_raises(self, nc_mcp: McpTestHelper) -
async def test_restore_twice_same_version(self, nc_mcp: McpTestHelper) -> None:
file_id = await _create_versioned_file(nc_mcp, "twice")
versions = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
oldest = sorted(versions, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions, key=lambda v: int(v["version_id"]))
await nc_mcp.call("restore_version", file_id=file_id, version_id=oldest["version_id"])
versions_mid = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
v1_entries = [v for v in versions_mid if v["size"] == oldest["size"]]
Expand All @@ -187,14 +187,14 @@ async def test_read_only_allows_list(self, nc_mcp: McpTestHelper, nc_mcp_read_on
async def test_read_only_blocks_restore(self, nc_mcp: McpTestHelper, nc_mcp_read_only: McpTestHelper) -> None:
file_id = await _create_versioned_file(nc_mcp, "perm-block")
versions = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
oldest = sorted(versions, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions, key=lambda v: int(v["version_id"]))
with pytest.raises(ToolError, match=r"[Pp]ermission"):
await nc_mcp_read_only.call("restore_version", file_id=file_id, version_id=oldest["version_id"])

@pytest.mark.asyncio
async def test_write_allows_restore(self, nc_mcp: McpTestHelper, nc_mcp_write: McpTestHelper) -> None:
file_id = await _create_versioned_file(nc_mcp, "perm-write")
versions = json.loads(await nc_mcp.call("list_versions", file_id=file_id, limit=200))["data"]
oldest = sorted(versions, key=lambda v: int(v["version_id"]))[0]
oldest = min(versions, key=lambda v: int(v["version_id"]))
result = await nc_mcp_write.call("restore_version", file_id=file_id, version_id=oldest["version_id"])
assert "Restored" in result