diff --git a/.github/workflows/tests-integration.yml b/.github/workflows/tests-integration.yml index 1db50b7..668c0f5 100644 --- a/.github/workflows/tests-integration.yml +++ b/.github/workflows/tests-integration.yml @@ -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 }} @@ -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" @@ -120,7 +124,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud-version: ["32", "33"] + nextcloud-version: ["34"] services: nextcloud: image: nextcloud:${{ matrix.nextcloud-version }} @@ -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) @@ -191,7 +195,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud-version: ["32", "33"] + nextcloud-version: ["34"] services: nextcloud: image: nextcloud:${{ matrix.nextcloud-version }} @@ -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" diff --git a/README.md b/README.md index 674d126..33d236f 100644 --- a/README.md +++ b/README.md @@ -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/) @@ -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 @@ -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 diff --git a/codecov.yml b/codecov.yml index 19f7774..5954163 100644 --- a/codecov.yml +++ b/codecov.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 094b784..b6f55a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ lint.select = [ lint.extend-ignore = [ "I001", "PLR0913", + "PLR0917", "PLR2004", "PLW0603", "S311", diff --git a/src/nc_mcp_server/tools/calendar.py b/src/nc_mcp_server/tools/calendar.py index a8bbd35..13ead44 100644 --- a/src/nc_mcp_server/tools/calendar.py +++ b/src/nc_mcp_server/tools/calendar.py @@ -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 @@ -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) diff --git a/src/nc_mcp_server/tools/tasks.py b/src/nc_mcp_server/tools/tasks.py index bf99669..803dfdc 100644 --- a/src/nc_mcp_server/tools/tasks.py +++ b/src/nc_mcp_server/tools/tasks.py @@ -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) diff --git a/tests/integration/test_circles.py b/tests/integration/test_circles.py index e9f38b8..f62004d 100644 --- a/tests/integration/test_circles.py +++ b/tests/integration/test_circles.py @@ -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) diff --git a/tests/integration/test_versions.py b/tests/integration/test_versions.py index e1b2be0..570e9e8 100644 --- a/tests/integration/test_versions.py +++ b/tests/integration/test_versions.py @@ -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) @@ -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 @@ -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 @@ -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"]] @@ -187,7 +187,7 @@ 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"]) @@ -195,6 +195,6 @@ async def test_read_only_blocks_restore(self, nc_mcp: McpTestHelper, nc_mcp_read 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