Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
71 changes: 71 additions & 0 deletions .github/workflows/tests-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,74 @@ jobs:
if: failure()
run: |
docker exec ${{ job.services.nextcloud.id }} cat /var/www/html/data/nextcloud.log 2>/dev/null | tail -50 || echo "No logs found"

test-user-permissions:
name: User permissions (NC ${{ matrix.nextcloud-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
nextcloud-version: ["32", "33"]
services:
nextcloud:
image: nextcloud:${{ matrix.nextcloud-version }}
env:
SQLITE_DATABASE: nextcloud
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: admin
ports:
- 8080:80
options: >-
--health-cmd "curl -f http://localhost/status.php || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 30
--health-start-period 60s
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Wait for Nextcloud
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:8080/status.php | python3 -c "import sys,json; sys.exit(0 if json.load(sys.stdin)['installed'] else 1)" 2>/dev/null; then
echo "Nextcloud is ready"
break
fi
echo "Waiting... ($i)"
sleep 3
done

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

- name: Run user permission tests
env:
NEXTCLOUD_URL: http://localhost:8080
NEXTCLOUD_USER: admin
NEXTCLOUD_PASSWORD: admin
run: pytest tests/integration/test_user_permissions.py -v -m integration --cov=nc_mcp_server --cov-report=xml:coverage-user-perms.xml

- name: Upload coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5
with:
files: coverage-user-perms.xml
flags: user-permissions
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false

- name: Dump Nextcloud logs on failure
if: failure()
run: |
docker exec ${{ job.services.nextcloud.id }} cat /var/www/html/data/nextcloud.log 2>/dev/null | tail -50 || echo "No logs found"
8 changes: 7 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
- [x] Files Trashbin tools: list_trash, restore_trash_item, empty_trash (2026-03-27)
- [x] Files Versions tools: list_versions, restore_version (2026-03-27)
- [x] Mail tools: list_mail_accounts, list_mailboxes, list_mail_messages, get_mail_message, send_mail (2026-03-28)
- [x] Collectives tools: list_collectives, get_collective_pages, get_collective_page (2026-03-29)
- [x] App Management tools: list_apps, get_app_info, enable_app, disable_app (2026-03-30)
- [x] User-permission integration tests: non-admin error handling validation (2026-03-30)

### In Progress

Expand Down Expand Up @@ -66,10 +69,13 @@
| Shares | 5 | 40 |
| System Tags | 6 | 22 |
| Mail | 5 | 29 |
| Collectives | 3 | 22 |
| App Management | 4 | 14 |
| User Permissions | — | 15 |
| Server | — | 7 |
| Permissions | — | 34 |
| Errors | — | 16 |
| Client | — | 29 |
| Config | — | 17 |
| State | — | 2 |
| **Total** | **60** | **491** |
| **Total** | **67** | **542** |
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: 7
after_n_builds: 9

comment:
after_n_builds: 7
after_n_builds: 9
require_changes: true

coverage:
Expand Down
2 changes: 2 additions & 0 deletions src/nc_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .tools import (
activity,
announcements,
app_management,
collectives,
comments,
files,
Expand Down Expand Up @@ -51,6 +52,7 @@ def create_server(config: Config | None = None) -> FastMCP:

activity.register(mcp)
announcements.register(mcp)
app_management.register(mcp)
collectives.register(mcp)
comments.register(mcp)
files.register(mcp)
Expand Down
116 changes: 116 additions & 0 deletions src/nc_mcp_server/tools/app_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""App management tools — list, enable, and disable Nextcloud apps via OCS API."""

import json
from typing import Any

from mcp.server.fastmcp import FastMCP

from ..annotations import ADDITIVE_IDEMPOTENT, DESTRUCTIVE, READONLY
from ..permissions import PermissionLevel, require_permission
from ..state import get_client

APPS_API = "cloud/apps"


def _format_app(a: dict[str, Any]) -> dict[str, Any]:
return {
"id": a.get("id"),
"name": a.get("name"),
"summary": a.get("summary"),
"version": a.get("version"),
"author": a.get("author"),
}


def _register_read_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=READONLY)
@require_permission(PermissionLevel.READ)
async def list_apps(app_filter: str = "enabled") -> str:
"""List installed Nextcloud apps. Requires admin privileges.

Returns the list of app IDs matching the filter criteria.

Args:
app_filter: Filter apps by status. Options:
"enabled" (default) — only enabled apps
"disabled" — only disabled apps
"all" — all installed apps

Returns:
JSON list of app ID strings.
"""
valid = {"enabled", "disabled", "all"}
if app_filter not in valid:
raise ValueError(f"Invalid filter '{app_filter}'. Must be one of: {', '.join(sorted(valid))}")
client = get_client()
params = {} if app_filter == "all" else {"filter": app_filter}
data = await client.ocs_get(APPS_API, params=params)
return json.dumps(data["apps"], indent=2)

@mcp.tool(annotations=READONLY)
@require_permission(PermissionLevel.READ)
async def get_app_info(app_id: str) -> str:
"""Get detailed information about an installed Nextcloud app. Requires admin privileges.

Returns app metadata including name, version, description, and author.

Args:
app_id: The app identifier (e.g. "spreed", "mail", "collectives").
Use list_apps to find available app IDs.

Returns:
JSON object with app details: id, name, summary, version, author.
"""
client = get_client()
data = await client.ocs_get(f"{APPS_API}/{app_id}")
return json.dumps(_format_app(data), indent=2, default=str)


def _register_write_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=ADDITIVE_IDEMPOTENT)
@require_permission(PermissionLevel.WRITE)
async def enable_app(app_id: str) -> str:
"""Enable a Nextcloud app. Requires admin privileges.

Activates a previously disabled or newly installed app. This makes
the app's features available to users.

Args:
app_id: The app identifier to enable (e.g. "spreed", "mail").

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_post(f"{APPS_API}/{app_id}")
return f"App '{app_id}' enabled."


def _register_destructive_tools(mcp: FastMCP) -> None:
@mcp.tool(annotations=DESTRUCTIVE)
@require_permission(PermissionLevel.DESTRUCTIVE)
async def disable_app(app_id: str) -> str:
"""Disable a Nextcloud app. Requires admin privileges.

Deactivates the app, making its features unavailable. The app data
is preserved and can be re-enabled later.

Be careful: disabling core apps (like files, dav) can break
Nextcloud functionality.

Args:
app_id: The app identifier to disable (e.g. "weather_status").

Returns:
Confirmation message.
"""
client = get_client()
await client.ocs_delete(f"{APPS_API}/{app_id}")
return f"App '{app_id}' disabled."


def register(mcp: FastMCP) -> None:
"""Register app management tools with the MCP server."""
_register_read_tools(mcp)
_register_write_tools(mcp)
_register_destructive_tools(mcp)
112 changes: 112 additions & 0 deletions tests/integration/test_app_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Integration tests for App Management tools against a real Nextcloud instance."""

import json

import pytest
from mcp.server.fastmcp.exceptions import ToolError

from .conftest import McpTestHelper

pytestmark = pytest.mark.integration

SAFE_APP = "weather_status"


class TestListApps:
@pytest.mark.asyncio
async def test_list_enabled_returns_list(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("list_apps")
apps: list[str] = json.loads(result)
assert isinstance(apps, list)
assert len(apps) > 0

@pytest.mark.asyncio
async def test_list_enabled_contains_core_apps(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("list_apps", app_filter="enabled")
apps = json.loads(result)
assert "files" in apps
assert "dav" in apps

@pytest.mark.asyncio
async def test_list_all(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("list_apps", app_filter="all")
apps = json.loads(result)
assert len(apps) > 0

@pytest.mark.asyncio
async def test_list_disabled(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("list_apps", app_filter="disabled")
apps = json.loads(result)
assert isinstance(apps, list)

@pytest.mark.asyncio
async def test_invalid_filter_raises(self, nc_mcp: McpTestHelper) -> None:
with pytest.raises((ToolError, ValueError)):
await nc_mcp.call("list_apps", app_filter="invalid")


class TestGetAppInfo:
@pytest.mark.asyncio
async def test_get_known_app(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("get_app_info", app_id="files")
app = json.loads(result)
assert app["id"] == "files"
assert app["name"] is not None
assert app["version"] is not None

@pytest.mark.asyncio
async def test_get_app_has_fields(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("get_app_info", app_id="dav")
app = json.loads(result)
for field in ["id", "name", "version", "author"]:
assert field in app, f"Missing field: {field}"
assert app[field] is not None, f"Field '{field}' should not be None"

@pytest.mark.asyncio
async def test_get_nonexistent_app_raises(self, nc_mcp: McpTestHelper) -> None:
with pytest.raises(ToolError):
await nc_mcp.call("get_app_info", app_id="nonexistent_app_xyz")


class TestEnableDisableApp:
@pytest.mark.asyncio
async def test_disable_and_enable(self, nc_mcp: McpTestHelper) -> None:
await nc_mcp.call("disable_app", app_id=SAFE_APP)
try:
disabled = json.loads(await nc_mcp.call("list_apps", app_filter="disabled"))
assert SAFE_APP in disabled
await nc_mcp.call("enable_app", app_id=SAFE_APP)
enabled = json.loads(await nc_mcp.call("list_apps", app_filter="enabled"))
assert SAFE_APP in enabled
finally:
await nc_mcp.call("enable_app", app_id=SAFE_APP)

@pytest.mark.asyncio
async def test_enable_already_enabled(self, nc_mcp: McpTestHelper) -> None:
result = await nc_mcp.call("enable_app", app_id=SAFE_APP)
assert "enabled" in result.lower()

@pytest.mark.asyncio
async def test_disable_returns_confirmation(self, nc_mcp: McpTestHelper) -> None:
try:
result = await nc_mcp.call("disable_app", app_id=SAFE_APP)
assert "disabled" in result.lower()
finally:
await nc_mcp.call("enable_app", app_id=SAFE_APP)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class TestAppManagementPermissions:
@pytest.mark.asyncio
async def test_read_only_allows_list(self, nc_mcp_read_only: McpTestHelper) -> None:
result = await nc_mcp_read_only.call("list_apps")
assert isinstance(json.loads(result), list)

@pytest.mark.asyncio
async def test_read_only_blocks_enable(self, nc_mcp_read_only: McpTestHelper) -> None:
with pytest.raises(ToolError, match=r"requires 'write' permission"):
await nc_mcp_read_only.call("enable_app", app_id=SAFE_APP)

@pytest.mark.asyncio
async def test_read_only_blocks_disable(self, nc_mcp_read_only: McpTestHelper) -> None:
with pytest.raises(ToolError, match=r"requires 'destructive' permission"):
await nc_mcp_read_only.call("disable_app", app_id=SAFE_APP)
4 changes: 4 additions & 0 deletions tests/integration/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,14 @@
"delete_share",
"delete_tag",
"delete_user",
"disable_app",
"dismiss_all_notifications",
"dismiss_notification",
"edit_comment",
"empty_trash",
"enable_app",
"get_activity",
"get_app_info",
"get_collective_page",
"get_collective_pages",
"get_conversation",
Expand All @@ -52,6 +55,7 @@
"get_user_status",
"leave_conversation",
"list_announcements",
"list_apps",
"list_collectives",
"list_comments",
"list_conversations",
Expand Down
Loading
Loading