Compare commits

..

1 Commits

Author SHA1 Message Date
Chris Coutinho b047fce290 feat(server): Add /heartbeat endpoint 2025-09-12 10:45:18 +02:00
142 changed files with 3014 additions and 30195 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
*
!pyproject.toml
!poetry.lock
!README.md
!uv.lock
!nextcloud_mcp_server/**/*.py
!nextcloud_mcp_server/
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
-33
View File
@@ -1,33 +0,0 @@
name: Release
on:
push:
tags:
- v*
jobs:
pypi:
name: Publish to PyPI
runs-on: ubuntu-latest
# Environment and permissions trusted publishing.
environment:
# Create this environment in the GitHub repository under Settings -> Environments
name: pypi
permissions:
id-token: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
run: uv build
- name: Smoke test (wheel)
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
- name: Smoke test (source distribution)
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
- name: Publish
run: uv publish
+4 -29
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -25,38 +25,13 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
submodules: 'true'
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
- name: Run docker compose
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
with:
compose-file: "./docker-compose.yml"
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
- name: Install Playwright dependencies
run: |
uv run playwright install chromium --with-deps
uses: astral-sh/setup-uv@557e51de59eb14aaaba2ed9621916900a91d50c6 # v6
- name: Wait for service to be ready
run: |
@@ -81,4 +56,4 @@ jobs:
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run pytest -v --log-cli-level=INFO
uv run --frozen python -m pytest
-3
View File
@@ -4,6 +4,3 @@ __pycache__/
*.env
.env.local
.env.*.local
# Generated by pytest used to login users
.nextcloud_oauth_*.json
-6
View File
@@ -1,6 +0,0 @@
[submodule "oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
[submodule "third_party/oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
-132
View File
@@ -1,135 +1,3 @@
## v0.18.0 (2025-10-23)
### Feat
- **server**: Add support for custom OIDC scopes and permissions via JWTs
- Initialize JWT-scoped tools
### Fix
- Use occ-created OAuth clients with allowed_scopes for all tests
- Separate OAuth fixtures for opaque vs JWT tokens
### Refactor
- Update JWT client to use DCR, re-enable tool filtering
## v0.17.1 (2025-10-20)
### Fix
- **caldav**: Fix caldav search() due to missing todos
## v0.17.0 (2025-10-19)
### Feat
- **caldav**: Add support for tasks
### Fix
- **caldav**: Check that calendar exists after creation to avoid race condition
- **caldav**: Properly parse datetimes as vDDDTypes
### Refactor
- Migrate from internal CalendarClient to caldav library
## v0.16.0 (2025-10-19)
### Feat
- **webdav**: Add search and list favorite response tools
### Perf
- **notes**: Improve notes search performance using async iterators
## v0.15.2 (2025-10-17)
### Refactor
- Unify logging & remove factory deployment
## v0.15.1 (2025-10-17)
### Fix
- Increase HTTP client timeout to 30s
- Handle RequestError in mcp tools
## v0.15.0 (2025-10-17)
### Feat
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
## v0.14.3 (2025-10-17)
### Fix
- **deps**: update dependency mcp to >=1.18,<1.19
## v0.14.2 (2025-10-16)
### Fix
- **deps**: update dependency pillow to v12
## v0.14.1 (2025-10-15)
### Fix
- **oauth**: Remove the option to force_register new clients
## v0.14.0 (2025-10-15)
### Feat
- Add Groups API client
- add sharing API client and server tools
- **users**: Initialize user API client
### Fix
- Update user/groups API to OCS v2
## v0.13.0 (2025-10-13)
### Feat
- **server**: Experimental support for OAuth2/OIDC authentication
## v0.12.6 (2025-10-11)
### Fix
- **deps**: update dependency mcp to >=1.17,<1.18
## v0.12.5 (2025-10-03)
### Fix
- **deps**: update dependency mcp to >=1.16,<1.17
## v0.12.4 (2025-09-25)
### Fix
- **deps**: update dependency mcp to >=1.15,<1.16
## v0.12.3 (2025-09-23)
### Refactor
- Add tools for all resources to enable tool-only workflows
## v0.12.2 (2025-09-20)
### Refactor
- Add `http` to --transport option
## v0.12.1 (2025-09-11)
### Fix
+14 -302
View File
@@ -5,86 +5,20 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Development Commands
### Testing
The test suite is organized in layers for fast feedback:
```bash
# FAST FEEDBACK (recommended for development)
# Unit tests only - ~5 seconds
uv run pytest tests/unit/ -v
# Smoke tests - critical path validation - ~30-60 seconds
uv run pytest -m smoke -v
# INTEGRATION TESTS
# Integration tests without OAuth - ~2-3 minutes
uv run pytest -m "integration and not oauth" -v
# Full test suite - ~4-5 minutes
# Run all tests
uv run pytest
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
uv run pytest -m oauth -v
# Run integration tests only
uv run pytest -m integration
# COVERAGE
# Run tests with coverage
uv run pytest --cov
# LEGACY COMMANDS (still work)
# Run all integration tests
uv run pytest -m integration -v
# Skip integration tests
uv run pytest -m "not integration" -v
uv run pytest -m "not integration"
```
### Load Testing
```bash
# Run benchmark with default settings (10 workers, 30 seconds)
uv run python -m tests.load.benchmark
# Quick test with custom concurrency and duration
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
# Extended load test (50 workers for 5 minutes)
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON for analysis
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
# Test OAuth server on port 8001
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
# Verbose mode with detailed logging
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
```
**Load Testing Features:**
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
- **Real-time progress** bar with live RPS and error counts
- **Detailed metrics**:
- Throughput (requests/second)
- Latency percentiles (p50, p90, p95, p99)
- Per-operation breakdown
- Error rates and types
- **Automatic cleanup** of test data
- **JSON export** for CI/CD integration
- **Server health checks** before starting
**Understanding Results:**
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
- **Latency**:
- p50 (median): Should be <100ms for most operations
- p95: Should be <500ms
- p99: Should be <1000ms
- **Error Rate**: Should be <1% under normal load
**Common Bottlenecks:**
1. Nextcloud backend API response times (most common)
2. Database connection limits
3. HTTP client connection pooling
4. Network I/O between containers
### Code Quality
```bash
# Format and lint code
@@ -104,23 +38,13 @@ mcp run --transport sse nextcloud_mcp_server.app:mcp
# Docker development environment with Nextcloud instance
docker-compose up
# After code changes, rebuild and restart the appropriate MCP server container:
# For basic auth changes (most common) - uses admin credentials
# After code changes, rebuild and restart only the MCP server container
docker-compose up --build -d mcp
# For OAuth changes - uses OAuth authentication with JWT tokens
docker-compose up --build -d mcp-oauth
# Build Docker image
docker build -t nextcloud-mcp-server .
```
**Important: MCP Server Containers**
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
- JWT tokens are used for testing (faster validation, scopes embedded in token)
- The server can handle both JWT and opaque tokens via the token verifier
### Environment Setup
```bash
# Install dependencies
@@ -130,36 +54,6 @@ uv sync
uv sync --group dev
```
### Database Inspection
**Docker Compose Database Credentials:**
- Root user: `root` / password: `password`
- App user: `nextcloud` / password: `password`
- Database: `nextcloud`
**Common Database Commands:**
```bash
# Connect to database as root (most common for inspection)
docker compose exec db mariadb -u root -ppassword nextcloud
# Check OAuth clients
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
# Check OAuth client scopes
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
# Check OAuth access tokens
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
```
**Important Tables:**
- `oc_oidc_clients` - OAuth client registrations (DCR clients)
- `oc_oidc_client_scopes` - Client allowed scopes
- `oc_oidc_access_tokens` - Issued access tokens
- `oc_oidc_authorization_codes` - Authorization codes
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management
- `oc_oidc_redirect_uris` - Redirect URIs for each client
## Architecture Overview
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
@@ -187,17 +81,7 @@ Each Nextcloud app has a corresponding server module that:
### Supported Nextcloud Apps
- **Notes** - Full CRUD operations and search
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
- **Calendar Operations**: List, create, delete calendars
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
- Priority levels (0-9, 1=highest, 9=lowest)
- Due dates, start dates, completion tracking
- Percent complete (0-100%)
- Categories and filtering
- Search across all calendars
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
- **Calendar** - CalDAV integration with events, recurring events, attendees
- **Contacts** - CardDAV integration with address book operations
- **Tables** - Row-level operations on Nextcloud Tables
- **WebDAV** - Complete file system access
@@ -210,203 +94,31 @@ Each Nextcloud app has a corresponding server module that:
4. **Context injection** - MCP context provides access to the authenticated client instance
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
### MCP Response Patterns
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
**Pattern:**
1. Client methods return `List[Dict]` (raw data)
2. MCP tools convert to Pydantic models and wrap in response object
3. Response models inherit from `BaseResponse`, include `results` field + metadata
**Reference implementations:**
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
### Testing Structure
The test suite follows a layered architecture for fast feedback:
```
tests/
├── unit/ # Fast unit tests (~5s total)
│ ├── test_scope_decorator.py
│ └── test_response_models.py
├── smoke/ # Critical path tests (~30-60s)
│ └── test_smoke.py
├── integration/
│ ├── client/ # Direct API layer tests
│ │ ├── notes/
│ │ ├── calendar/
│ │ └── ...
│ └── server/ # MCP tool layer tests
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
│ │ ├── test_oauth_core.py
│ │ ├── test_scope_authorization.py
│ │ └── ...
│ ├── test_mcp.py
│ └── ...
└── load/ # Performance tests
```
**Test Markers:**
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
- `@pytest.mark.integration` - Integration tests requiring Docker containers
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
- `@pytest.mark.smoke` - Critical path smoke tests
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
- **Important**: Integration tests run against live Docker containers. After making code changes:
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
- Tests are marked with `@pytest.mark.integration` for selective running
- **Important**: Integration tests run against live Docker containers. After making code changes to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
#### Testing Best Practices
- **MANDATORY: Always run tests after implementing features or fixing bugs**
- Run tests to completion before considering any task complete
- If tests require modifications to pass, ask for permission before proceeding
- **Rebuild the correct container** after code changes:
- For basic auth tests (most common): `docker-compose up --build -d mcp`
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
- `nc_mcp_client` - MCP client session for tool/resource testing
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
- `temporary_note` - Creates and cleans up test notes automatically
- `temporary_addressbook` - Creates and cleans up test address books
- `temporary_contact` - Creates and cleans up test contacts
- **Test specific functionality** after changes:
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
#### Writing Mocked Unit Tests
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
**Pattern:**
```python
import httpx
import pytest
from nextcloud_mcp_server.client.notes import NotesClient
from tests.conftest import create_mock_note_response
async def test_notes_api_get_note(mocker):
"""Test that get_note correctly parses the API response."""
# Create mock response using helper functions
mock_response = create_mock_note_response(
note_id=123,
title="Test Note",
content="Test content",
category="Test",
etag="abc123",
)
# Mock the _make_request method
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NotesClient, "_make_request", return_value=mock_response
)
# Create client and test
client = NotesClient(mock_client, "testuser")
note = await client.get_note(note_id=123)
# Verify the response was parsed correctly
assert note["id"] == 123
assert note["title"] == "Test Note"
# Verify the correct API endpoint was called
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
```
**Mock Response Helpers in `tests/conftest.py`:**
- `create_mock_response()` - Generic HTTP response builder
- `create_mock_note_response()` - Pre-configured note response
- `create_mock_error_response()` - Error responses (404, 412, etc.)
**Benefits:**
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
- 🔒 No Docker dependency
- 🎯 Tests focus on response parsing logic
- ♻️ Repeatable and deterministic
**When to use:**
- Testing client methods that parse JSON responses
- Testing error handling (404, 412, etc.)
- Testing request parameter building
**When NOT to use (keep as integration tests):**
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
- Multi-component workflows (Notes + WebDAV attachments)
- OAuth flows
- End-to-end MCP tool testing
**Reference Implementation:**
- See `tests/client/notes/test_notes_api.py` for complete examples
- Mark unit tests with `pytestmark = pytest.mark.unit`
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
#### OAuth/OIDC Testing
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
**OAuth Testing Setup:**
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
- Matches production MCP server behavior (one client, multiple user tokens)
- Each user gets their own unique access token
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
**Example Commands:**
```bash
# Run all OAuth tests with Playwright automation using Firefox
uv run pytest tests/server/oauth/ --browser firefox -v
# Run specific OAuth test file with visible browser for debugging
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
uv run pytest -m oauth -v
```
**Test Environment:**
- **Two MCP server containers are available:**
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
**CI/CD Notes:**
- Playwright tests run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
## Integration testing with docker
### Nextcloud
- The `app` container is running nextcloud.
- Use `docker compose exec app php occ ...` to get a list of available commands
### Mariadb
- The `db` container is running mariadb
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
+2 -7
View File
@@ -1,7 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
# Install git (required for caldav dependency from git)
RUN apk add --no-cache git
FROM ghcr.io/astral-sh/uv:0.8.17-python3.11-alpine@sha256:2a2cae80b7d3b3b3c7f94ec3ed91e9b3ca2524a7a429824fbbadd9954fa5d6b6
WORKDIR /app
@@ -9,6 +6,4 @@ COPY . .
RUN uv sync --locked --no-dev
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
ENTRYPOINT ["/app/.venv/bin/python", "-m", "nextcloud_mcp_server.app", "--host", "0.0.0.0"]
+199 -251
View File
@@ -2,306 +2,254 @@
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
**Enable AI assistants to interact with your Nextcloud instance.**
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models (LLMs) like OpenAI's GPT, Google's Gemini, or Anthropic's Claude to interact with your Nextcloud instance. This enables automation of various Nextcloud actions, starting with the Notes API.
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
## Features
> [!NOTE]
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also _[exposes an MCP server](https://docs.nextcloud.com/server/stable/admin_manual/ai/app_context_agent.html#using-nextcloud-mcp-server)_ for external MCP clients.
>
> This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. It does not require any additional AI-features to be enabled in Nextcloud beyond the apps that you intend to interact with.
The server provides integration with multiple Nextcloud apps, enabling LLMs to interact with your Nextcloud data through a rich set of tools and resources.
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
## Supported Nextcloud Apps
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|--------|---------------------------------------------|--------------------------------------------------------|
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
| App | Support Status | Description |
|-----|----------------|-------------|
| **Notes** | ✅ Full Support | Create, read, update, delete, and search notes. Handle attachments via WebDAV. |
| **Calendar** | ✅ Full Support | Complete calendar integration - create, update, delete events. Support for recurring events, reminders, attendees, and all-day events via CalDAV. |
| **Tables** | ⚠️ Row Operations | Read table schemas and perform CRUD operations on table rows. Table management not yet supported. |
| **Files (WebDAV)** | ✅ Full Support | Complete file system access - browse directories, read/write files, create/delete resources. |
| **Contacts** | ✅ Full Support | Create, read, update, and delete contacts and address books via CardDAV. |
| **Deck** | ✅ Full Support | Complete project management - boards, stacks, cards, labels, user assignments. Full CRUD operations and advanced features. |
| **Tasks** | ❌ [Not Started](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) | TBD |
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
Is there a Nextcloud app not present in this list that you'd like to be
included? Feel free to open an issue, or contribute via a pull-request.
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
## Available Tools & Resources
### Authentication
Resources provide read-only access to data for browsing and discovery. Unlike tools, resources are automatically listed by MCP clients and enable LLMs to explore your Nextcloud data structure.
| Mode | Security | Best For |
|------|----------|----------|
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
| **Basic Auth** ✅ | Lower | Development, testing, production |
### Core Resources
| Resource | Description |
|----------|-------------|
| `nc://capabilities` | Access Nextcloud server capabilities |
| `notes://settings` | Access Notes app settings |
| `nc://Notes/{note_id}/attachments/{attachment_filename}` | Access attachments for notes |
> [!IMPORTANT]
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
> - **Production use**: Wait for upstream patch to be merged into official releases
>
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
### Tools vs Resources
## Quick Start
**Tools** are for actions and operations:
- Create, update, delete operations
- Structured responses with validation
- Error handling and business logic
- Examples: `deck_create_card`, `deck_update_stack`
### 1. Install
**Resources** are for data browsing and discovery:
- Read-only access to existing data
- Automatic listing by MCP clients
- Raw data format for exploration
- Examples: `nc://Deck/boards/{board_id}`, `nc://Deck/boards/{board_id}/stacks`
```bash
# Clone the repository
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
# Install with uv (recommended)
uv sync
## Installation
# Or using Docker
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Prerequisites
See [Installation Guide](docs/installation.md) for detailed instructions.
* Python 3.11+
* Access to a Nextcloud instance
### 2. Configure
### Local Installation
Create a `.env` file:
1. Clone the repository (if running from source):
```bash
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
```
2. Install the package dependencies (if running via CLI):
```bash
uv sync
```
```bash
# Copy the sample
cp env.sample .env
```
3. Run the CLI --help command to see all available options
```bash
$ uv run python -m nextcloud_mcp_server.app --help
Usage: python -m nextcloud_mcp_server.app [OPTIONS]
Options:
-h, --host TEXT [default: 127.0.0.1]
-p, --port INTEGER [default: 8000]
-w, --workers INTEGER
-r, --reload
-l, --log-level [critical|error|warning|info|debug|trace]
[default: info]
-t, --transport [sse|streamable-http]
[default: sse]
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
Enable specific Nextcloud app APIs. Can be
specified multiple times. If not specified,
all apps are enabled.
--help Show this message and exit.
```
### Docker
A pre-built Docker image is available: `ghcr.io/cbcoutinho/nextcloud-mcp-server`
## Configuration
The server requires credentials to connect to your Nextcloud instance. Create a file named `.env` (or any name you prefer) in the directory where you'll run the server, based on the `env.sample` file:
**For Basic Auth (recommended for most users):**
```dotenv
# .env
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_USERNAME=your_username
NEXTCLOUD_PASSWORD=your_app_password
NEXTCLOUD_USERNAME=your_nextcloud_username
NEXTCLOUD_PASSWORD=your_nextcloud_app_password_or_login_password
```
**For OAuth (experimental - requires patches):**
```dotenv
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
```
* `NEXTCLOUD_HOST`: The full URL of your Nextcloud instance.
* `NEXTCLOUD_USERNAME`: Your Nextcloud username.
* `NEXTCLOUD_PASSWORD`: **Important:** It is highly recommended to use a dedicated Nextcloud App Password for security. You can generate one in your Nextcloud Security settings. Alternatively, you can use your regular login password, but this is less secure.
See [Configuration Guide](docs/configuration.md) for all options.
## Transport Types
### 3. Set Up Authentication
The server supports two transport types for MCP communication:
**Basic Auth Setup (recommended):**
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
2. Add credentials to `.env` file
3. Start the server
**OAuth Setup (experimental):**
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration or create an OIDC client with id & secret
4. Configure Bearer token validation in `user_oidc`
5. Start the server
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
### 4. Run the Server
### Streamable HTTP (Recommended)
The `streamable-http` transport is the recommended and modern transport type that provides improved streaming capabilities:
```bash
# Load environment variables
# Use streamable-http transport (recommended)
uv run python -m nextcloud_mcp_server.app --transport streamable-http
```
### SSE (Server-Sent Events) - Deprecated
> [!WARNING]
> ⚠️ **Deprecated**: SSE transport is deprecated and will be removed in a future version of the MCP spec. SSE will be supported for the foreseable future, but users are encouraged to switch to the new transport type. Please migrate to `streamable-http`.
```bash
# SSE transport (deprecated - for backwards compatibility only)
uv run python -m nextcloud_mcp_server.app --transport sse
```
#### Docker Usage with Transports
```bash
# Using SSE transport (default - deprecated)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# Using streamable-http transport (recommended)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
--transport streamable-http
```
**Note:** When using MCP clients, ensure your client supports the transport type you've configured on the server. Most modern MCP clients support streamable-http.
## Running the Server
### Locally
Ensure your environment variables are loaded, then run the server. You have several options:
#### Option 1: Using `nextcloud_mcp_server` cli (recommended)
```bash
# Load environment variables from your .env file
export $(grep -v '^#' .env | xargs)
# Start with Basic Auth (default)
uv run nextcloud-mcp-server
# Run the app module directly with custom options
uv run python -m nextcloud_mcp_server.app --host 0.0.0.0 --port 8080 --log-level info
# Or start with OAuth (experimental - requires patches)
uv run nextcloud-mcp-server --oauth
# Enable only specific Nextcloud app APIs
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
# Or with Docker
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# Enable only WebDAV for file operations
uv run python -m nextcloud_mcp_server.app --enable-app webdav
```
The server starts on `http://127.0.0.1:8000` by default.
#### Option 2: Using `uvicorn`
See [Running the Server](docs/running.md) for more options.
You can also run the MCP server with `uvicorn` directly, which enables support
for all uvicorn arguments (e.g. `--reload`, `--workers`).
### 5. Connect an MCP Client
```bash
# Load environment variables from your .env file
export $(grep -v '^#' .env | xargs)
Test with MCP Inspector:
# Run with uvicorn using the --factory option
uv run uvicorn nextcloud_mcp_server.app:get_app --factory --reload --host 127.0.0.1 --port 8000
```
The server will start, typically listening on `http://127.0.0.1:8000`.
**Host binding options:**
- Use `--host 0.0.0.0` to bind to all interfaces
- Use `--host 127.0.0.1` to bind only to localhost (default)
See the full list of available `uvicorn` options and how to set them at [https://www.uvicorn.org/settings/]()
### Selective App Enablement
By default, all supported Nextcloud app APIs are enabled. You can selectively enable only specific apps using the `--enable-app` option:
```bash
# Available apps: notes, tables, webdav, calendar, contacts, deck
# Enable all apps (default behavior)
uv run python -m nextcloud_mcp_server.app
# Enable only Notes and Calendar
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app calendar
# Enable only WebDAV for file operations
uv run python -m nextcloud_mcp_server.app --enable-app webdav
# Enable multiple apps by repeating the option
uv run python -m nextcloud_mcp_server.app --enable-app notes --enable-app tables --enable-app contacts
```
This can be useful for:
- Reducing memory usage and startup time
- Limiting available functionality for security or organizational reasons
- Testing specific app integrations
- Running lightweight instances with only needed features
### Using Docker
Mount your environment file when running the container:
```bash
# Run with all apps enabled (default)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# Run with only specific apps enabled
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
--enable-app notes --enable-app calendar
# Run with only WebDAV
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
--enable-app webdav
```
This will start the server and expose it on port 8000 of your local machine.
## Usage
Once the server is running, you can connect to it using an MCP client like `MCP Inspector`. Once your MCP server is running, launch MCP Inspector as follows:
```bash
uv run mcp dev
```
Or connect from:
- Claude Desktop
- Any MCP-compatible client
You can then connect to and interact with the server's tools and resources through your browser.
## Documentation
## References:
### Getting Started
- **[Installation](docs/installation.md)** - Install the server
- **[Configuration](docs/configuration.md)** - Environment variables and settings
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
- **[Running the Server](docs/running.md)** - Start and manage the server
### Architecture
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
### OAuth Documentation (Experimental)
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
### Reference
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
### App-Specific Documentation
- [Notes API](docs/notes.md)
- [Calendar (CalDAV)](docs/calendar.md)
- [Contacts (CardDAV)](docs/contacts.md)
- [Cookbook](docs/cookbook.md)
- [Deck](docs/deck.md)
- [Tables](docs/table.md)
- [WebDAV](docs/webdav.md)
## MCP Tools & Resources
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
### Tools
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
#### Available Tool Categories
| App | Tools | Read Scope | Write Scope | Operations |
|-----|-------|-----------|-------------|------------|
| **Notes** | 7 | `mcp:notes:read` | `mcp:notes:write` | Create, read, update, delete, search notes |
| **Calendar** | 20+ | `mcp:calendar:read` | `mcp:calendar:write` | Events, todos (tasks), calendars, recurring events, attendees |
| **Contacts** | 8 | `mcp:contacts:read` | `mcp:contacts:write` | Create, read, update, delete contacts and address books |
| **Files (WebDAV)** | 12 | `mcp:files:read` | `mcp:files:write` | List, read, upload, delete, move files and folders |
| **Deck** | 15 | `mcp:deck:read` | `mcp:deck:write` | Boards, stacks, cards, labels, assignments |
| **Cookbook** | 13 | `mcp:cookbook:read` | `mcp:cookbook:write` | Recipes, import from URLs, search, categories |
| **Tables** | 5 | `mcp:tables:read` | `mcp:tables:write` | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | `mcp:sharing:read` | `mcp:sharing:write` | Create, manage, delete shares |
**Example Tools:**
- `nc_notes_create_note` - Create a new note
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
- `deck_create_card` - Create a Deck card
- `nc_calendar_create_event` - Create a calendar event
- `nc_calendar_create_todo` - Create a CalDAV task/todo
- `nc_contacts_create_contact` - Create a contact
- `nc_webdav_upload_file` - Upload a file to Nextcloud
- And 80+ more...
> [!TIP]
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `mcp:notes:read` and `mcp:notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
>
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
### Resources
Resources provide read-only access to Nextcloud data:
- `nc://capabilities` - Server capabilities
- `cookbook://version` - Cookbook app version info
- `nc://Deck/boards/{board_id}` - Deck board data
- `notes://settings` - Notes app settings
- And more...
Run `uv run nextcloud-mcp-server --help` to see all available options.
## Examples
### Create a Note
```
AI: "Create a note called 'Meeting Notes' with today's agenda"
→ Uses nc_notes_create_note tool
```
### Manage Recipes
```
AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake"
→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata
```
### Manage Calendar
```
AI: "Schedule a team meeting for next Tuesday at 2pm"
→ Uses nc_calendar_create_event tool
```
### Organize Files
```
AI: "Create a folder called 'Project X' and move all PDFs there"
→ Uses WebDAV tools (nc_webdav_create_directory, nc_webdav_move)
```
### Project Management
```
AI: "Create a new Deck board for Q1 planning with Todo, In Progress, and Done stacks"
→ Uses deck_create_board and deck_create_stack tools
```
## Transport Protocols
The server supports multiple MCP transport protocols:
- **streamable-http** (recommended) - Modern streaming protocol
- **sse** (default, deprecated) - Server-Sent Events for backward compatibility
- **http** - Standard HTTP protocol
```bash
# Use streamable-http (recommended)
uv run nextcloud-mcp-server --transport streamable-http
```
> [!WARNING]
> SSE transport is deprecated and will be removed in a future MCP specification version. Please migrate to `streamable-http`.
- https://github.com/modelcontextprotocol/python-sdk
## Contributing
Contributions are welcome!
- Report bugs or request features: [GitHub Issues](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
- Submit improvements: [Pull Requests](https://github.com/cbcoutinho/nextcloud-mcp-server/pulls)
- Read [CLAUDE.md](CLAUDE.md) for development guidelines
## Security
[![MseeP.ai Security Assessment](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
This project takes security seriously:
- OAuth2/OIDC support (experimental - requires upstream patches)
- Basic Auth with app-specific passwords (recommended)
- No credential storage with OAuth mode
- Per-user access tokens
- Regular security assessments
Found a security issue? Please report it privately to the maintainers.
Contributions are welcome! Please feel free to submit issues or pull requests on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server).
## License
This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) for details.
This project is licensed under the AGPL-3.0 License. See the [LICENSE](./LICENSE) file for details.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=cbcoutinho/nextcloud-mcp-server&type=Date)](https://www.star-history.com/#cbcoutinho/nextcloud-mcp-server&Date)
## References
- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [Nextcloud](https://nextcloud.com/)
[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/cbcoutinho-nextcloud-mcp-server-badge.png)](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
@@ -1,5 +0,0 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
@@ -1,69 +0,0 @@
From deab2dac3d73d25f20a95c18103f327ab48f837a Mon Sep 17 00:00:00 2001
From: Chris Coutinho <chris@coutinho.io>
Date: Sun, 12 Oct 2025 21:09:29 +0200
Subject: [PATCH 1/1] Fix Bearer token authentication causing session logout
When using Bearer token authentication with OIDC, API requests to
endpoints with @CORS annotations (like Notes API) were failing with
401 Unauthorized errors. This occurred because:
1. Bearer token validation successfully authenticated the user
2. A session was created for the authenticated user
3. Nextcloud's CORSMiddleware detected the logged-in session but no
CSRF token, causing it to call session->logout()
4. The logout invalidated the session, breaking the API request
This fix sets the 'app_api' session flag during Bearer token
authentication, which instructs CORSMiddleware to skip the CSRF check
and logout logic. This is the same mechanism used by Nextcloud's
AppAPI framework for external application authentication.
The flag is set at all successful Bearer token authentication points:
- Line 243: After OIDC Identity Provider validation
- Line 310: After auto-provisioning with bearer provisioning
- Line 315: After existing user authentication
- Line 337: After LDAP user sync
Fixes: Bearer token authentication for all Nextcloud APIs
Tested-with: nextcloud-mcp-server integration tests
Signed-off-by: Chris Coutinho <chris@coutinho.io>
---
lib/User/Backend.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/lib/User/Backend.php b/lib/User/Backend.php
index 23cfb18..65665cc 100644
--- a/lib/User/Backend.php
+++ b/lib/User/Backend.php
@@ -240,6 +240,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
$this->eventDispatcher->dispatchTyped($validationEvent);
$oidcProviderUserId = $validationEvent->getUserId();
if ($oidcProviderUserId !== null) {
+ $this->session->set('app_api', true);
return $oidcProviderUserId;
} else {
$this->logger->debug('[NextcloudOidcProviderValidator] The bearer token validation has failed');
@@ -306,10 +307,12 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $userId;
} elseif ($this->userExists($tokenUserId)) {
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
} else {
// check if the user exists locally
@@ -331,6 +334,7 @@ class Backend extends ABackend implements IPasswordConfirmationBackend, IGetDisp
}
$this->checkFirstLogin($tokenUserId);
$this->session->set('last-password-confirm', strtotime('+4 year', time()));
+ $this->session->set('app_api', true);
return $tokenUserId;
}
}
--
2.51.0
@@ -1,5 +0,0 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable cookbook
@@ -1,38 +0,0 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring OIDC app for testing..."
# Check if development OIDC app is mounted at /opt/apps/oidc
if [ -d /opt/apps/oidc ]; then
echo "Development OIDC app found at /opt/apps/oidc"
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/oidc ]; then
echo "Removing existing OIDC in custom_apps..."
rm -rf /var/www/html/custom_apps/oidc
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable oidc
elif [ -d /var/www/html/custom_apps/oidc ]; then
echo "OIDC app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable oidc
else
echo "OIDC app not found, installing from app store..."
php /var/www/html/occ app:install oidc
php /var/www/html/occ app:enable oidc
fi
# Configure OIDC Identity Provider with dynamic client registration enabled
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
echo "OIDC app installed and configured successfully"
@@ -1,13 +0,0 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring user_oidc app for testing..."
# Enable the user_oidc app (OIDC client for bearer token validation)
php /var/www/html/occ app:enable user_oidc
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
@@ -1,23 +1,19 @@
#!/bin/bash
set -euox pipefail
set -e # Exit on any error
echo "Installing and configuring Calendar app..."
# Enable calendar app
php /var/www/html/occ app:enable calendar
php /var/www/html/occ app:enable tasks
# Wait for calendar app to be fully initialized
echo "Waiting for calendar app to initialize..."
sleep 5
# Disable rate limits on calendar creation for integration tests
# Set to -1 to completely disable rate limiting
# Reference: https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html#rate-limits
# Increase limits on calendar creation for integration tests (100 in 60s)
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
# Ensure maintenance mode is off before calendar operations
php /var/www/html/occ maintenance:mode --off
@@ -1,5 +1,3 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable contacts
@@ -1,5 +1,3 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable deck
@@ -1,5 +1,3 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable notes
@@ -1,5 +1,3 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable tables
+10 -39
View File
@@ -3,7 +3,7 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: docker.io/library/mariadb:lts@sha256:ae6119716edac6998ae85508431b3d2e666530ddf4e94c61a10710caec9b0f71
image: mariadb:lts@sha256:ec5d50f32359ff020b93cce6834f9bf89147c34aea0e90c952ccf556c94a4fb8
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
@@ -17,23 +17,24 @@ services:
# Note: Redis is an external service. You can find more information about the configuration here:
# https://hub.docker.com/_/redis
redis:
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
restart: always
app:
image: docker.io/library/nextcloud:32.0.0@sha256:f9bec5c77a8d5603354b990550a4d24487deae6e589dd20ce870e43e28460e18
image: nextcloud:31.0.8@sha256:c3329db9d0d0d79b1fe6433b54b81c28acaefecfe96a400be202b7da80f6b8ca
#user: www-data:www-data
restart: always
#post_start:
#- command: chown -R www-data:www-data /var/www/html && while ! nc -z db 3306; do sleep 1; echo sleeping; done
#user: root
ports:
- 0.0.0.0:8080:80
- 127.0.0.1:8080:80
depends_on:
- redis
- db
volumes:
- nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party/oidc:/opt/apps/oidc:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -42,49 +43,19 @@ services:
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
recipes:
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
mcp:
build: .
command: ["--transport", "streamable-http"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8000:8000
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_USERNAME=admin
- NEXTCLOUD_PASSWORD=admin
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
# JWT token type is used for testing (faster validation, scopes embedded in token)
volumes:
- oauth-client-storage:/app/.oauth
#volumes:
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
volumes:
nextcloud:
db:
oauth-client-storage:
-161
View File
@@ -1,161 +0,0 @@
# Authentication
The Nextcloud MCP server supports two authentication modes for connecting to your Nextcloud instance.
## Authentication Modes Comparison
| Mode | Status | Security | Use Case |
|------|--------|----------|----------|
| **OAuth2/OIDC** | ✅ Recommended | 🔒 High | Production deployments, multi-user scenarios |
| **Basic Auth** | ⚠️ Legacy | ⚠️ Lower | Development, backward compatibility |
## OAuth2/OIDC (Recommended)
OAuth2/OIDC authentication provides secure, token-based authentication following modern security standards.
### Architecture
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources:
```
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
OAuth Flow with PKCE Bearer Token Auth
```
**Key Components**:
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
- **Nextcloud `user_oidc` app**: Token validation middleware
- **MCP Client**: Any MCP-compatible client (Claude, custom clients)
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
### Required Nextcloud Apps
OAuth authentication requires **two Nextcloud apps** to work together:
#### 1. `oidc` - OIDC Identity Provider
**Purpose:** Makes Nextcloud an OAuth2/OIDC authorization server
**Provides:**
- OAuth2 authorization endpoint (`/apps/oidc/authorize`)
- Token endpoint (`/apps/oidc/token`)
- User info endpoint (`/apps/oidc/userinfo`)
- JWKS endpoint for token validation (`/apps/oidc/jwks`)
- Dynamic client registration endpoint (`/apps/oidc/register`)
**Installation:** Available in Nextcloud App Store under "Security"
#### 2. `user_oidc` - OpenID Connect User Backend
**Purpose:** Authenticates users and validates Bearer tokens
**Provides:**
- Bearer token validation against the OIDC provider
- User authentication via OIDC
- Session management for authenticated users
**Installation:** Available in Nextcloud App Store under "Security"
**Important:** The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (like Notes API). See [Upstream Status](oauth-upstream-status.md) for details.
### Benefits
- **Zero-config deployment** via dynamic client registration
- **No credential storage** in environment variables
- **Per-user authentication** with access tokens
- **Per-user permissions** - each user has their own Nextcloud client
- **Automatic token validation** via Nextcloud OIDC userinfo endpoint
- **Token caching** for performance (default: 1 hour TTL)
- **PKCE required** for enhanced security (S256 code challenge)
- **Secure by design** following OAuth 2.0 and OpenID Connect standards
### Current Implementation Limitations
> [!IMPORTANT]
> **Tested Configuration:**
> - ✅ Nextcloud `oidc` app (OIDC Identity Provider) + `user_oidc` app (OIDC User Backend)
> - ✅ Nextcloud acting as its own identity provider (self-hosted OIDC)
> - ✅ MCP server as OAuth Resource Server
> - ✅ PKCE with S256 code challenge method
>
> **Not Tested:**
> - ❌ External identity providers (Azure AD, Keycloak, Okta, etc.)
> - ❌ Using `user_oidc` with external OIDC providers
>
> **Known Requirements:**
> - 🔧 The `user_oidc` app requires a patch for Bearer token support on non-OCS endpoints (see [Upstream Status](oauth-upstream-status.md))
> - ⏱️ Dynamic client registration credentials expire (default: 1 hour) - use pre-configured clients for production
> - 🔐 PKCE must be advertised in OIDC discovery (see [Upstream Status](oauth-upstream-status.md))
### How OAuth Works
The MCP server implements the OAuth 2.0 Resource Server pattern:
**Phase 1: Authorization (OAuth Flow with PKCE)**
1. MCP client connects and receives OAuth settings (issuer URL, scopes)
2. Client initiates OAuth flow with PKCE (Proof Key for Code Exchange)
3. User authenticates via browser to Nextcloud
4. Nextcloud redirects back with authorization code
5. Client exchanges code + code_verifier for access token
**Phase 2: API Access (Bearer Token Validation)**
6. Client sends MCP requests with `Authorization: Bearer <token>` header
7. MCP server validates token by calling Nextcloud's userinfo endpoint
8. Server creates per-user NextcloudClient instance with the token
9. All Nextcloud API requests use the user's Bearer token
10. User-specific permissions and audit trails apply
This ensures:
- Each user has their own authenticated session
- Actions appear from the correct user in Nextcloud logs
- Proper permission boundaries are maintained
- No shared credentials between users
### See Also
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed production setup
- [OAuth Architecture](oauth-architecture.md) - Technical details
- [Upstream Status](oauth-upstream-status.md) - Required patches and PR status
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific issues
- [Configuration](configuration.md) - Environment variables
## Basic Authentication (Legacy)
Basic Authentication uses username and password credentials directly.
### Benefits
- **Simple setup** with username/password
- **Single-user** server instances
- **Quick for development** and testing
### Limitations
- **Credentials in environment** (less secure)
- **Single user only** - all requests use the same account
- **No audit trail** - all actions appear from the same user
- **Maintained for compatibility** - will be deprecated in future versions
> [!WARNING]
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. It's maintained for backward compatibility only and may be deprecated in future versions. Use OAuth for production deployments.
### See Also
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
## Mode Detection
The server automatically detects the authentication mode:
- **OAuth mode**: When `NEXTCLOUD_USERNAME` and `NEXTCLOUD_PASSWORD` are NOT set
- **BasicAuth mode**: When both username and password are provided
You can also force a specific mode using CLI flags:
```bash
# Force OAuth mode
uv run nextcloud-mcp-server --oauth
# Force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
```
## Switching Between Modes
See [Troubleshooting: Switching Between OAuth and BasicAuth](troubleshooting.md#switching-between-oauth-and-basicauth) for instructions.
-698
View File
@@ -1,698 +0,0 @@
# MCP Server Comparison: Nextcloud MCP Server vs Context Agent
This document compares the two MCP server implementations in the Nextcloud ecosystem:
1. **Nextcloud MCP Server** (this project) - Standalone MCP server for external access to Nextcloud
2. **Context Agent MCP Server** - MCP server embedded within Nextcloud as an External App
## Executive Summary
Both projects expose Nextcloud functionality via the Model Context Protocol (MCP), but serve different purposes and audiences:
- **Nextcloud MCP Server**: Brings Nextcloud OUT to external MCP clients (Claude Code, etc.)
- **Context Agent**: Brings external MCP servers IN to Nextcloud's AI Assistant
## Architecture Overview
```mermaid
graph TB
subgraph External["External Clients"]
CC[Claude Code]
IDE[IDEs with MCP]
APP[Other MCP Clients]
end
subgraph NMCP["Nextcloud MCP Server<br/>(This Project)"]
NMCP_Server[FastMCP Server]
NMCP_Client[HTTP Clients]
NMCP_Auth[OAuth/BasicAuth]
end
subgraph NC["Nextcloud Instance"]
subgraph CA["Context Agent ExApp"]
CA_Agent[LangGraph Agent]
CA_MCP[MCP Server /mcp]
CA_Tools[Tool Loader]
end
NC_Apps[Nextcloud Apps<br/>Notes, Calendar, Files, etc.]
NC_Assistant[Assistant App]
end
subgraph ExtMCP["External MCP Servers"]
Weather[Weather MCP]
Other[Other Services]
end
%% External clients connect to standalone MCP server
CC --> NMCP_Server
IDE --> NMCP_Server
APP --> NMCP_Server
%% Standalone MCP server talks to Nextcloud over HTTP
NMCP_Server --> NMCP_Auth
NMCP_Auth --> NMCP_Client
NMCP_Client -->|HTTP/HTTPS| NC_Apps
%% Context Agent is inside Nextcloud
CA_Agent --> CA_Tools
CA_Tools --> NC_Apps
CA_MCP -->|Exposes to| NC_Assistant
NC_Assistant -->|User requests| CA_Agent
%% Context Agent can consume external MCP servers
CA_Tools -->|Consumes| ExtMCP
%% Context Agent could consume Nextcloud MCP Server
CA_Tools -.->|Could consume| NMCP_Server
classDef external fill:#e1f5ff
classDef standalone fill:#fff4e1
classDef internal fill:#e8f5e9
class CC,IDE,APP external
class NMCP_Server,NMCP_Client,NMCP_Auth standalone
class CA_Agent,CA_MCP,CA_Tools,NC_Apps,NC_Assistant internal
```
## Deployment Models
```mermaid
graph LR
subgraph Deploy1["Nextcloud MCP Server Deployment"]
direction TB
D1[Docker Container]
D2[Cloud VM]
D3[Local Machine]
D4[Kubernetes Pod]
end
subgraph Deploy2["Context Agent Deployment"]
direction TB
NC[Nextcloud Instance<br/>with AppAPI]
ExApp[External App Container<br/>Managed by Nextcloud]
end
Deploy1 -.->|HTTP/HTTPS| NC
ExApp -->|Integrated| NC
classDef deploy fill:#fff4e1
classDef integrated fill:#e8f5e9
class D1,D2,D3,D4 deploy
class NC,ExApp integrated
```
### Nextcloud MCP Server
- **Location**: Runs anywhere with network access to Nextcloud
- **Deployment**: Docker, VM, local machine, Kubernetes
- **Connection**: HTTP/HTTPS to Nextcloud APIs
- **Independence**: Fully standalone service
### Context Agent
- **Location**: Runs inside Nextcloud as External App
- **Deployment**: Managed by Nextcloud AppAPI
- **Connection**: Native nc-py-api integration
- **Integration**: Deep Nextcloud integration
## Authentication Architecture
```mermaid
graph TB
subgraph NMCP_Auth["Nextcloud MCP Server Authentication"]
direction TB
Client1[MCP Client]
subgraph BasicAuth["BasicAuth Mode"]
BA_Shared[Shared NextcloudClient]
BA_Creds[Username + Password]
end
subgraph OAuth["OAuth Mode"]
OAuth_Token[OAuth Token]
OAuth_Verify[Token Verifier]
OAuth_OIDC[OIDC Discovery]
OAuth_Client[Per-Request Client]
end
Client1 -->|Basic Auth| BasicAuth
Client1 -->|Bearer Token| OAuth
BA_Creds --> BA_Shared
OAuth_Token --> OAuth_Verify
OAuth_OIDC --> OAuth_Verify
OAuth_Verify --> OAuth_Client
end
subgraph CA_Auth["Context Agent Authentication"]
direction TB
Client2[MCP Client]
CA_Header[Authorization Header]
CA_OCS[OCS API Validation]
CA_User[User Context]
CA_NC[nc-py-api Client]
Client2 --> CA_Header
CA_Header --> CA_OCS
CA_OCS -->|Extract user_id| CA_User
CA_User -->|nc.set_user| CA_NC
end
classDef auth fill:#fff4e1
classDef user fill:#e1f5ff
class BasicAuth,OAuth auth
class CA_User user
```
## Tool Registration & Loading
```mermaid
sequenceDiagram
participant Startup
participant NMCP as Nextcloud MCP<br/>Server
participant CA as Context Agent
participant Request as Client Request
Note over Startup,NMCP: Nextcloud MCP Server (Static)
Startup->>NMCP: Server starts
NMCP->>NMCP: configure_notes_tools(mcp)
NMCP->>NMCP: configure_calendar_tools(mcp)
NMCP->>NMCP: configure_contacts_tools(mcp)
Note over NMCP: Tools registered once<br/>at startup
Request->>NMCP: Call tool
NMCP->>NMCP: Use pre-registered tool
Note over Startup,CA: Context Agent (Dynamic)
Startup->>CA: Server starts
CA->>CA: Install ToolListMiddleware
Request->>CA: List tools (or 60s elapsed)
CA->>CA: get_tools(nc)
CA->>CA: Import all_tools/*.py
CA->>CA: Call module.get_tools(nc)
CA->>CA: Regenerate tool functions
Note over CA: Tools refreshed every 60s<br/>or on demand
Request->>CA: Call tool
CA->>CA: Regenerate with fresh nc
```
## Tool Definition Patterns
### Nextcloud MCP Server
```python
# Static registration at startup
def configure_notes_tools(mcp: FastMCP):
@mcp.tool()
async def nc_notes_create_note(
title: str,
content: str,
category: str,
ctx: Context
) -> CreateNoteResponse:
"""Create a new note"""
client = get_client(ctx) # Auto-detects auth mode
note_data = await client.notes.create_note(
title=title,
content=content,
category=category
)
return CreateNoteResponse(
id=note_data["id"],
title=note_data["title"],
etag=note_data["etag"]
)
# Resources for structured data access
@mcp.resource("nc://Notes/{note_id}")
async def nc_get_note_resource(note_id: int):
"""Get user note using note id"""
ctx = mcp.get_context()
client = get_client(ctx)
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
```
**Key Features**:
- Native FastMCP `@mcp.tool()` decorator
- Pydantic models for type safety
- MCP Resources support
- Comprehensive error handling with McpError
- Context-based client resolution
### Context Agent
```python
# Dynamic loading at runtime
async def get_tools(nc: Nextcloud):
@tool
@safe_tool
def list_calendars():
"""List all existing calendars by name"""
principal = nc.cal.principal()
calendars = principal.calendars()
return ", ".join([cal.name for cal in calendars])
@tool
@dangerous_tool
def schedule_event(
calendar_name: str,
title: str,
description: str,
start_date: str,
end_date: str,
attendees: list[str] | None,
start_time: str | None,
end_time: str | None
):
"""Create a new event or meeting in a calendar"""
# Parse dates and times
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
# ... event creation logic
principal = nc.cal.principal()
calendar = {cal.name: cal for cal in calendars}[calendar_name]
calendar.add_event(str(c))
return True
return [list_calendars, schedule_event, ...]
def get_category_name():
return "Calendar and Tasks"
def is_available(nc: Nextcloud):
return True # or check capabilities
```
**Key Features**:
- LangChain `@tool` decorator
- `@safe_tool` / `@dangerous_tool` decorators
- Dynamic tool regeneration with fresh context
- Tools returned as list from async function
- Availability checking per module
## Client Architecture
```mermaid
graph TB
subgraph NMCP_Client["Nextcloud MCP Server Clients"]
direction TB
NMCP_Main[NextcloudClient]
NMCP_Base[BaseNextcloudClient]
NMCP_Notes[NotesClient]
NMCP_Cal[CalendarClient]
NMCP_Contacts[ContactsClient]
NMCP_Tables[TablesClient]
NMCP_WebDAV[WebDAVClient]
NMCP_Deck[DeckClient]
NMCP_Main --> NMCP_Notes
NMCP_Main --> NMCP_Cal
NMCP_Main --> NMCP_Contacts
NMCP_Main --> NMCP_Tables
NMCP_Main --> NMCP_WebDAV
NMCP_Main --> NMCP_Deck
NMCP_Notes -.->|extends| NMCP_Base
NMCP_Cal -.->|extends| NMCP_Base
NMCP_Contacts -.->|extends| NMCP_Base
NMCP_Base --> HTTPX["httpx.AsyncClient"]
NMCP_Base --> Retry["@retry_on_429"]
end
subgraph CA_Client["Context Agent Client"]
direction TB
CA_NC["nc-py-api<br/>NextcloudApp"]
CA_NC --> CA_Cal["nc.cal<br/>CalDAV"]
CA_NC --> CA_Talk["nc.talk<br/>Talk API"]
CA_NC --> CA_OCS["nc.ocs<br/>OCS API"]
CA_NC --> CA_Session["nc._session<br/>HTTP Adapter"]
end
HTTPX -->|"HTTP/HTTPS"| NextcloudAPI["Nextcloud APIs"]
CA_Session -->|"HTTP/HTTPS"| NextcloudAPI
classDef custom fill:#fff4e1
classDef native fill:#e8f5e9
class NMCP_Main,NMCP_Base,NMCP_Notes,NMCP_Cal custom
class CA_NC,CA_Cal,CA_Talk,CA_OCS native
```
## Functionality Comparison
### Available Tools & Features
| Feature Category | Nextcloud MCP Server | Context Agent MCP |
|-----------------|---------------------|-------------------|
| **Notes** | ✅ Full CRUD, search, attachments (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV (events, recurring, attendees) | ✅ Schedule events, list calendars, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (address books, contacts) | ✅ Find person, current user details (2 tools) |
| **Files** | ✅ Full WebDAV (read, write, directories) | ✅ Get content, folder tree, sharing (3 tools) |
| **Tables** | ✅ Row CRUD operations | ❌ Not implemented |
| **Deck** | ✅ Boards, stacks, cards | ✅ Create board, add card (2 tools) |
| **Talk** | ❌ Not implemented | ✅ List/send messages, create conversation (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email, list mailboxes (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, audio2text, doc-gen, context_chat (4 tools) |
| **Web Search** | ❌ Not implemented | ✅ DuckDuckGo, YouTube search (2 tools) |
| **Location** | ❌ Not implemented | ✅ OpenStreetMap, HERE transit, weather (3 tools) |
| **OpenProject** | ❌ Not implemented | ✅ Integration (2 tools) |
| **MCP Resources** | ✅ notes://, nc:// URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server only | ✅ Consumes external MCP servers |
| **Sharing** | ✅ Share management API | ❌ Not implemented |
| **Capabilities** | ✅ Server info resource | ❌ Not exposed |
### Tool Count Summary
- **Nextcloud MCP Server**: ~50+ tools and resources
- Deep integration with specific apps
- Full CRUD operations
- MCP Resources for structured data
- **Context Agent**: ~28+ tools
- Broader feature coverage
- Action-oriented (agent tasks)
- Can aggregate external MCP servers
## Tool Safety & Confirmation
### Context Agent Safety Model
```mermaid
graph TD
Request[User Request] --> Agent[LangGraph Agent]
Agent --> Model[LLM generates tool calls]
Model --> Check{Tool type?}
Check -->|"@safe_tool"| Execute[Execute immediately]
Check -->|"@dangerous_tool"| Queue[Queue for confirmation]
Queue --> UserNode[Request user confirmation]
UserNode -->|Approved| Execute
UserNode -->|Denied| Cancel[Cancel with reason]
Execute --> Result[Return result to agent]
Cancel --> Result
Result --> Agent
classDef safe fill:#e8f5e9
classDef danger fill:#ffe8e8
class Execute safe
class Queue,UserNode,Cancel danger
```
**Safe Tools** (read-only):
- `list_calendars`
- `find_person_in_contacts`
- `list_talk_conversations`
- `get_file_content`
- `get_folder_tree`
**Dangerous Tools** (write operations):
- `schedule_event`
- `send_message_to_conversation`
- `create_public_sharing_link`
- `send_email`
### Nextcloud MCP Server Safety
**No built-in safety classification**:
- All tools treated equally
- Relies on MCP client for validation
- OAuth scopes could control permissions
- User must review all actions
## Error Handling
### Nextcloud MCP Server
```python
try:
note_data = await client.notes.create_note(...)
return CreateNoteResponse(...)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(ErrorData(
code=-1,
message="Access denied: insufficient permissions"
))
elif e.response.status_code == 413:
raise McpError(ErrorData(
code=-1,
message="Note content too large"
))
elif e.response.status_code == 409:
raise McpError(ErrorData(
code=-1,
message="Note with this title already exists"
))
```
**Features**:
- Comprehensive HTTP status code handling
- User-friendly error messages
- Specific error codes
- Guidance on resolution
### Context Agent
```python
def schedule_event(...):
"""Create event"""
# ... implementation
calendar.add_event(str(c))
return True # Simple boolean return
```
**Features**:
- Minimal error handling
- Exceptions propagate to agent
- LangChain handles retries
- Agent interprets failures
## Use Cases
### When to Use Nextcloud MCP Server
```mermaid
graph LR
Root[Nextcloud MCP Server]
Root --> ExtAccess[External Access]
Root --> OAuth[OAuth Security]
Root --> DeepAPI[Deep API Access]
Root --> Deploy[Standalone Deployment]
ExtAccess --> EA1[Claude Code integration]
ExtAccess --> EA2[IDE plugins with MCP]
ExtAccess --> EA3[Custom MCP clients]
ExtAccess --> EA4[Cross-platform tools]
OAuth --> O1[Token-based auth]
OAuth --> O2[OIDC compliance]
OAuth --> O3[Per-user permissions]
OAuth --> O4[Secure external access]
DeepAPI --> DA1[Full CRUD operations]
DeepAPI --> DA2[Notes management]
DeepAPI --> DA3[Calendar CalDAV]
DeepAPI --> DA4[Contacts CardDAV]
DeepAPI --> DA5[File operations]
DeepAPI --> DA6[Table data]
Deploy --> D1[Docker containers]
Deploy --> D2[Cloud VMs]
Deploy --> D3[Kubernetes]
Deploy --> D4[On-premise servers]
classDef rootStyle fill:#4a90e2,stroke:#2e5c8a,color:#fff
classDef categoryStyle fill:#f39c12,stroke:#d68910,color:#fff
classDef itemStyle fill:#e8f5e9,stroke:#81c784
class Root rootStyle
class ExtAccess,OAuth,DeepAPI,Deploy categoryStyle
class EA1,EA2,EA3,EA4,O1,O2,O3,O4,DA1,DA2,DA3,DA4,DA5,DA6,D1,D2,D3,D4 itemStyle
```
**Best for**:
1. External clients accessing Nextcloud (Claude Code, IDEs)
2. OAuth/OIDC authentication requirements
3. Full CRUD on Notes, Calendar, Contacts, Tables
4. WebDAV file system access
5. MCP Resources for structured data
6. Flexible deployment scenarios
7. Building external integrations
### When to Use Context Agent MCP Server
```mermaid
graph LR
Root[Context Agent MCP]
Root --> Assistant[AI Assistant]
Root --> ActionOriented[Action-Oriented]
Root --> MCPAgg[MCP Aggregation]
Root --> Safety[Safety Features]
Assistant --> A1[Nextcloud UI integration]
Assistant --> A2[Task Processing API]
Assistant --> A3[User requests in Assistant]
Assistant --> A4[Human-in-the-loop]
ActionOriented --> AO1[Send emails]
ActionOriented --> AO2[Create calendar events]
ActionOriented --> AO3[Post Talk messages]
ActionOriented --> AO4[Generate images]
ActionOriented --> AO5[Search web]
MCPAgg --> M1[Consume external MCP servers]
MCPAgg --> M2[Weather services]
MCPAgg --> M3[Maps and transit]
MCPAgg --> M4[Custom integrations]
MCPAgg --> M5[Unified tool interface]
Safety --> S1[Read operations auto-execute]
Safety --> S2[Write operations require approval]
Safety --> S3[User confirmation flow]
Safety --> S4[Agent safety]
classDef rootStyle fill:#9b59b6,stroke:#6c3483,color:#fff
classDef categoryStyle fill:#e74c3c,stroke:#c0392b,color:#fff
classDef itemStyle fill:#fff4e1,stroke:#f39c12
class Root rootStyle
class Assistant,ActionOriented,MCPAgg,Safety categoryStyle
class A1,A2,A3,A4,AO1,AO2,AO3,AO4,AO5,M1,M2,M3,M4,M5,S1,S2,S3,S4 itemStyle
```
**Best for**:
1. AI-driven actions inside Nextcloud UI
2. Assistant app integration
3. Safe/dangerous tool distinction
4. Talk, Mail, Deck operations
5. AI features (image gen, audio2text)
6. Web search and maps
7. Aggregating external MCP servers
8. Agent acting on behalf of users
## Complementary Architecture
The two MCP servers can work together in complementary ways:
```mermaid
graph TB
User[User] -->|Requests AI assistance| Assistant[Nextcloud Assistant App]
Assistant --> ContextAgent[Context Agent]
subgraph ContextAgent["Context Agent (Inside Nextcloud)"]
direction TB
Agent[LangGraph Agent]
MCPServer[MCP Server /mcp]
ToolLoader[Tool Loader]
Agent --> ToolLoader
ToolLoader --> InternalTools[Internal Tools<br/>Talk, Mail, Calendar]
end
subgraph ExternalMCP["External MCP Ecosystem"]
NextcloudMCP[Nextcloud MCP Server<br/>This Project]
WeatherMCP[Weather MCP]
CustomMCP[Custom MCP Services]
end
ToolLoader -->|Consumes| NextcloudMCP
ToolLoader -->|Consumes| WeatherMCP
ToolLoader -->|Consumes| CustomMCP
subgraph ExternalClients["External Clients"]
Claude[Claude Code]
IDE[IDEs with MCP]
end
Claude -->|Direct access| NextcloudMCP
IDE -->|Direct access| NextcloudMCP
NextcloudMCP -->|OAuth/HTTP| NextcloudApps[Nextcloud Apps<br/>Notes, Calendar, Files]
InternalTools -->|nc-py-api| NextcloudApps
classDef internal fill:#e8f5e9
classDef external fill:#e1f5ff
classDef mcp fill:#fff4e1
class Assistant,Agent,MCPServer,ToolLoader,InternalTools,NextcloudApps internal
class Claude,IDE external
class NextcloudMCP,WeatherMCP,CustomMCP mcp
```
### Example Workflows
**Workflow 1: External Client → Nextcloud MCP Server**
```
Claude Code → Nextcloud MCP Server → Nextcloud Notes API
```
- User asks Claude Code to search notes
- Claude Code calls `nc_notes_search_notes` tool
- Returns results directly to user
**Workflow 2: Assistant → Context Agent → Internal Tools**
```
User → Assistant → Context Agent → Send Email Tool
```
- User asks Assistant to send an email
- Context Agent identifies "send_email" as dangerous
- Requests user confirmation
- Sends email via nc-py-api
**Workflow 3: Assistant → Context Agent → External MCP**
```
User → Assistant → Context Agent → Nextcloud MCP Server → Notes
```
- User asks Assistant about notes
- Context Agent consumes Nextcloud MCP Server as external MCP
- Gets notes data via MCP protocol
- Returns to user via Assistant
## Technical Comparison Matrix
| Aspect | Nextcloud MCP Server | Context Agent MCP |
|--------|---------------------|-------------------|
| **Framework** | FastMCP (native) | FastMCP + LangChain |
| **Tool Decorator** | `@mcp.tool()` | `@tool` from LangChain |
| **Tool Loading** | Static (startup) | Dynamic (runtime) |
| **Tool Refresh** | No (restart required) | Every 60 seconds |
| **Resources** | Yes (`@mcp.resource()`) | No |
| **Transports** | SSE, HTTP, Streamable-HTTP | Stateless HTTP only |
| **MCP Mode** | Server only | Server + Client (hybrid) |
| **Client Type** | httpx (custom HTTP) | nc-py-api (native) |
| **Deployment** | Standalone external | Inside Nextcloud (ExApp) |
| **Auth** | BasicAuth or OAuth/OIDC | Session-based (ExApp) |
| **User Context** | Shared or per-token | Per-request `nc.set_user()` |
| **Error Handling** | McpError with codes | Basic exceptions |
| **Type Safety** | Pydantic models | Python types |
| **Safety Model** | No built-in | Safe/Dangerous classification |
| **Dependencies** | FastMCP, httpx, Pydantic | nc-py-api, LangChain, LangGraph |
| **Integration** | HTTP APIs | AppAPI + Task Processing |
| **External MCP** | No | Yes (consumes) |
## Summary
Both MCP servers serve important but different roles in the Nextcloud ecosystem:
### Nextcloud MCP Server (This Project)
- **Purpose**: Expose Nextcloud to external MCP clients
- **Strength**: Deep CRUD operations, OAuth security, standalone deployment
- **Audience**: External developers, Claude Code users, integration builders
### Context Agent MCP Server
- **Purpose**: Bring AI agent capabilities to Nextcloud users
- **Strength**: Action-oriented, safe/dangerous tools, MCP aggregation
- **Audience**: Nextcloud users via Assistant app, AI-driven workflows
**Key Insight**: These are complementary, not competing. Context Agent could even consume Nextcloud MCP Server as one of its external MCP sources, creating a unified ecosystem where:
- External clients access Nextcloud via Nextcloud MCP Server
- Internal users leverage Context Agent for AI assistance
- Context Agent aggregates both internal tools and external MCP servers (including Nextcloud MCP Server)
-253
View File
@@ -1,253 +0,0 @@
# Configuration
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
## Quick Start
Create a `.env` file based on `env.sample`:
```bash
cp env.sample .env
# Edit .env with your Nextcloud details
```
Then choose your authentication mode:
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
- [Basic Authentication Configuration](#basic-authentication-legacy)
---
## OAuth2/OIDC Configuration
OAuth2/OIDC is the recommended authentication mode for production deployments.
### Minimal Configuration (Auto-registration)
```dotenv
# .env file for OAuth with auto-registration
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
This minimal configuration uses dynamic client registration to automatically register an OAuth client at startup.
### Full Configuration (Pre-configured Client)
```dotenv
# .env file for OAuth with pre-configured client
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# OAuth Client Credentials (optional - auto-registers if not provided)
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# OAuth Storage and Callback Settings (optional)
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
### Prerequisites
Before using OAuth configuration:
1. **Install required Nextcloud apps** (both are required):
- **`oidc`** - OIDC Identity Provider (Apps → Security)
- **`user_oidc`** - OpenID Connect user backend (Apps → Security)
2. **Configure the apps**:
- Enable dynamic client registration (if using auto-registration) - Settings → OIDC
- Enable Bearer token validation: `php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean`
3. **Apply Bearer token patch** - The `user_oidc` app requires a patch for non-OCS endpoints - See [Upstream Status](oauth-upstream-status.md) for details
See the [OAuth Setup Guide](oauth-setup.md) for detailed step-by-step instructions, or [OAuth Quick Start](quickstart-oauth.md) for a 5-minute setup.
---
## Basic Authentication (Legacy)
Basic Authentication is maintained for backward compatibility. It uses username and password credentials.
> [!WARNING]
> **Security Notice:** Basic Authentication stores credentials in environment variables and is less secure than OAuth. Use OAuth for production deployments.
### Configuration
```dotenv
# .env file for BasicAuth mode
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_USERNAME=your_nextcloud_username
NEXTCLOUD_PASSWORD=your_app_password_or_password
```
### Environment Variables Reference
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | Full URL of your Nextcloud instance |
| `NEXTCLOUD_USERNAME` | ✅ Yes | Your Nextcloud username |
| `NEXTCLOUD_PASSWORD` | ✅ Yes | **Recommended:** Use a dedicated [Nextcloud App Password](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#managing-devices). Generate one in Nextcloud Security settings. Alternatively, use your login password (less secure). |
---
## Loading Environment Variables
After creating your `.env` file, load the environment variables:
### On Linux/macOS
```bash
# Load all variables from .env
export $(grep -v '^#' .env | xargs)
```
### On Windows (PowerShell)
```powershell
# Load variables from .env
Get-Content .env | ForEach-Object {
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
}
}
```
### Via Docker
```bash
# Docker automatically loads .env when using --env-file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
---
## CLI Configuration
Some configuration options can also be provided via CLI arguments. CLI arguments take precedence over environment variables.
### OAuth-related CLI Options
```bash
uv run nextcloud-mcp-server --help
Options:
--oauth / --no-oauth Force OAuth mode (if enabled) or
BasicAuth mode (if disabled). By default,
auto-detected based on environment
variables.
--oauth-client-id TEXT OAuth client ID (can also use
NEXTCLOUD_OIDC_CLIENT_ID env var)
--oauth-client-secret TEXT OAuth client secret (can also use
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
--oauth-storage-path TEXT Path to store OAuth client credentials
(can also use
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
[default: .nextcloud_oauth_client.json]
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
also use NEXTCLOUD_MCP_SERVER_URL env
var) [default: http://localhost:8000]
```
### Server Options
```bash
Options:
-h, --host TEXT Server host [default: 127.0.0.1]
-p, --port INTEGER Server port [default: 8000]
-w, --workers INTEGER Number of worker processes
-r, --reload Enable auto-reload
-l, --log-level [critical|error|warning|info|debug|trace]
Logging level [default: info]
-t, --transport [sse|streamable-http|http]
MCP transport protocol [default: sse]
```
### App Selection
```bash
Options:
-e, --enable-app [notes|tables|webdav|calendar|contacts|deck]
Enable specific Nextcloud app APIs. Can
be specified multiple times. If not
specified, all apps are enabled.
```
### Example CLI Usage
```bash
# OAuth mode with custom client and port
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789 \
--port 8080
# BasicAuth mode with specific apps only
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app calendar
```
---
## Configuration Best Practices
### For Development
- Use BasicAuth for quick setup and testing
- Or use OAuth with auto-registration (dynamic client registration)
- Store `.env` file in your project directory
- Add `.env` to `.gitignore`
### For Production
- **Always use OAuth2/OIDC** with pre-configured clients
- Store OAuth client credentials securely
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
- Never commit credentials to version control
- Set appropriate file permissions on credential storage:
```bash
chmod 600 .nextcloud_oauth_client.json
```
### For Docker
- Mount OAuth client storage as a volume for persistence:
```bash
docker run -v $(pwd)/.oauth:/app/.oauth --env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
- Use Docker secrets for sensitive values in production
---
## See Also
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
- [Authentication](authentication.md) - Authentication modes comparison
- [Running the Server](running.md) - Starting the server with different configurations
- [Troubleshooting](troubleshooting.md) - Common configuration issues
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
-189
View File
@@ -1,189 +0,0 @@
# Cookbook App
### Cookbook Tools
| Tool | Description |
|------|-------------|
| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata |
| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields |
| `nc_cookbook_get_recipe` | Get a specific recipe by ID |
| `nc_cookbook_update_recipe` | Update an existing recipe |
| `nc_cookbook_delete_recipe` | Delete a recipe permanently |
| `nc_cookbook_list_recipes` | Get all recipes in the database |
| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories |
| `nc_cookbook_list_categories` | Get all known recipe categories |
| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category |
| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags |
| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords |
| `nc_cookbook_set_config` | Set Cookbook app configuration |
| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database |
### Cookbook Resources
| Resource | Description |
|----------|-------------|
| `cookbook://version` | Get Cookbook app and API version information |
| `cookbook://config` | Get Cookbook app configuration |
| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID |
## Recipe Management
The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection:
- **Import recipes from websites** using schema.org metadata
- Full CRUD operations for recipes
- Search and organize with categories and keywords
- Support for structured recipe data (ingredients, instructions, nutrition, etc.)
- Configure app settings and trigger reindexing
### Schema.org Recipe Format
The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes:
- **Basic info**: Name, description, image, URL
- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`)
- **Ingredients**: List of ingredients with quantities
- **Instructions**: Step-by-step cooking instructions
- **Metadata**: Category, keywords/tags, yield (servings)
- **Nutrition**: Optional nutrition information
### Usage Examples
#### Import Recipe from URL
Many recipe websites include schema.org metadata. The import tool automatically extracts this data:
```python
# Import from a recipe website
await nc_cookbook_import_recipe(
url="https://www.example.com/recipes/chocolate-cake"
)
# Returns: Recipe object with all extracted data
```
#### Create Recipe Manually
```python
# Create a new recipe from scratch
await nc_cookbook_create_recipe(
name="Homemade Pizza",
description="Classic homemade pizza with fresh ingredients",
ingredients=[
"500g pizza dough",
"200g tomato sauce",
"300g mozzarella cheese",
"Fresh basil leaves",
"Olive oil"
],
instructions=[
"Preheat oven to 250°C (480°F)",
"Roll out the pizza dough",
"Spread tomato sauce evenly",
"Add mozzarella cheese",
"Bake for 10-12 minutes",
"Top with fresh basil and olive oil"
],
category="Main Course",
keywords="italian,vegetarian,quick",
prep_time="PT20M", # 20 minutes
cook_time="PT12M", # 12 minutes
total_time="PT32M", # 32 minutes
recipe_yield=4 # 4 servings
)
```
#### Update Recipe
```python
# Update recipe details (only specified fields are changed)
await nc_cookbook_update_recipe(
recipe_id=123,
description="Updated: Classic homemade pizza - now with video tutorial!",
url="https://example.com/videos/pizza-tutorial",
keywords="italian,vegetarian,quick,video"
)
```
#### Search and Filter
```python
# Search recipes by keyword
results = await nc_cookbook_search_recipes(query="chocolate")
# List all categories
categories = await nc_cookbook_list_categories()
# Returns: [{"name": "Desserts", "recipe_count": 15}, ...]
# Get recipes in a category
desserts = await nc_cookbook_get_recipes_in_category(category="Desserts")
# List all keywords/tags
keywords = await nc_cookbook_list_keywords()
# Returns: [{"name": "chocolate", "recipe_count": 8}, ...]
# Get recipes with specific tags
quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"])
```
#### Manage Configuration
```python
# Configure the Cookbook app
await nc_cookbook_set_config(
folder="Recipes", # Folder path in user's files
update_interval=15, # Auto-rescan every 15 minutes
print_image=True # Print images with recipes
)
# Trigger manual reindex after file changes
await nc_cookbook_reindex()
```
### Time Format (ISO8601 Duration)
Recipe times use ISO8601 duration format:
| Duration | Format | Example |
|----------|--------|---------|
| 15 minutes | `PT15M` | Prep time |
| 1 hour | `PT1H` | Baking time |
| 1 hour 30 minutes | `PT1H30M` | Total time |
| 45 seconds | `PT45S` | Mixing time |
| 2 hours 15 minutes | `PT2H15M` | Slow cooking |
### Tips for Recipe Import
**Best practices for importing recipes from URLs:**
1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata
2. **Check import quality**: Review imported recipes for completeness
3. **Handle duplicates**: The API prevents duplicate imports by recipe name
4. **Edit after import**: Update imported recipes with personal notes or adjustments
**Common recipe websites with good schema.org support:**
- AllRecipes
- Food Network
- BBC Good Food
- Serious Eats
- Bon Appétit
- Many food blogs using recipe plugins
### Organizing Your Recipes
**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.)
- Use `nc_cookbook_list_categories` to see all categories
- Filter by category with `nc_cookbook_get_recipes_in_category`
**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.)
- Use `nc_cookbook_list_keywords` to see all tags
- Filter by tags with `nc_cookbook_get_recipes_with_keywords`
- Search across all fields with `nc_cookbook_search_recipes`
**Reindexing**: The Cookbook app maintains a search index
- Automatically scans at configured intervals
- Manually trigger with `nc_cookbook_reindex` after bulk changes
- Required after modifying recipe files directly in WebDAV
## API Reference
For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2).
-215
View File
@@ -1,215 +0,0 @@
# Installation
This guide covers installing the Nextcloud MCP server on your system.
## Prerequisites
- **Python 3.11+** - Check with `python3 --version`
- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted
- **Administrator access** (for OAuth setup) - Required to install OIDC app
## Installation Methods
Choose one of the following installation methods:
- [From Source (Recommended)](#from-source-recommended)
- [Using Docker](#using-docker)
---
## From Source (Recommended)
Install from the GitHub repository using uv or pip.
### Prerequisites
Install [uv](https://github.com/astral-sh/uv) (recommended) or ensure pip is available:
```bash
# Install uv (recommended)
# On macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# On Windows
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
### Clone the Repository
```bash
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
```
### Install Dependencies
#### Using uv (Recommended)
```bash
# Install dependencies
uv sync
# Install development dependencies (optional)
uv sync --group dev
```
#### Using pip
```bash
# Create virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install in development mode
pip install -e .
# Install development dependencies (optional)
pip install -e ".[dev]"
```
### Verify Installation
```bash
# With uv
uv run nextcloud-mcp-server --help
# With pip/venv
nextcloud-mcp-server --help
```
---
## Using Docker
A pre-built Docker image is available for easy deployment.
### Pull the Image
```bash
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Run the Container
```bash
# Prepare your .env file first (see Configuration guide)
# Run with environment file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Docker Compose
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
# For persistent OAuth client storage
- ./oauth-storage:/app/.oauth
restart: unless-stopped
```
Start the service:
```bash
docker-compose up -d
```
---
## Next Steps
After installation:
1. **Configure the server** - See [Configuration Guide](configuration.md)
2. **Set up authentication** - See [OAuth Setup Guide](oauth-setup.md) or [Authentication](authentication.md)
3. **Run the server** - See [Running the Server](running.md)
## Updating
### Update from Source
```bash
cd nextcloud-mcp-server
git pull origin master
# Using uv
uv sync
# Or using pip
pip install -e .
```
### Update Docker Image
```bash
docker pull ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# If using docker-compose
docker-compose up -d # Restart with new image
# If using docker run
# Stop the old container and start a new one with the updated image
```
## Troubleshooting Installation
### Issue: "Python version too old"
**Cause:** Python 3.11+ is required.
**Solution:**
```bash
# Check your Python version
python3 --version
# Install Python 3.11+ from:
# - https://www.python.org/downloads/
# - Or use your system package manager (apt, brew, etc.)
```
### Issue: "Command not found: nextcloud-mcp-server"
**Cause:** The package is not in your PATH.
**Solution:**
```bash
# Ensure your virtual environment is activated
source venv/bin/activate
# Or use uv run
uv run nextcloud-mcp-server --help
# Or use python -m
python -m nextcloud_mcp_server.app --help
```
### Issue: Docker permission denied
**Cause:** Docker requires elevated permissions.
**Solution:**
```bash
# Add your user to the docker group (Linux)
sudo usermod -aG docker $USER
# Log out and back in
# Or use sudo
sudo docker run ...
```
## See Also
- [Configuration Guide](configuration.md) - Environment variables and settings
- [OAuth Setup Guide](oauth-setup.md) - OAuth authentication setup
- [Running the Server](running.md) - Starting and managing the server
-899
View File
@@ -1,899 +0,0 @@
# JWT OAuth Reference - Nextcloud MCP Server
**Last Updated:** 2025-10-23
**Status:** Production Ready
## Table of Contents
- [Overview](#overview)
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
- [Scope-Based Authorization](#scope-based-authorization)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Production Deployment](#production-deployment)
---
## Overview
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
- **Faster validation** - No HTTP call needed for token verification
- **Direct scope extraction** - Scopes embedded in token claims
- **Dynamic tool filtering** - Users only see tools they have permission to use
- **Signature verification** - Cryptographic validation using JWKS
### Key Features
-**JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
-**Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
-**Dynamic Tool Filtering** - Tools filtered based on user's token scopes
-**Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
-**Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
-**Backward Compatible** - BasicAuth mode bypasses all scope checks
### Supported Scopes
| Scope | Description | Tool Count |
|-------|-------------|------------|
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
---
## JWT vs Opaque Tokens
The Nextcloud OIDC app supports two token formats, configured per-client:
### JWT Tokens (Recommended)
**Advantages:**
- ✅ Fast validation - JWT signature verified locally using JWKS
- ✅ Direct scope extraction from `scope` claim in payload
- ✅ Standard approach (RFC 9068)
- ✅ No additional HTTP calls for validation
**Disadvantages:**
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
- ⚠️ Token payload visible to client (not an issue for access tokens)
**Token Structure:**
```json
{
"header": {
"typ": "at+JWT",
"alg": "RS256",
"kid": "..."
},
"payload": {
"iss": "http://localhost:8080",
"sub": "admin",
"aud": "client_id",
"exp": 1234567890,
"iat": 1234567890,
"scope": "openid profile email mcp:notes:read mcp:notes:write",
"client_id": "...",
"jti": "..."
}
}
```
### Opaque Tokens
**Advantages:**
- ✅ Smaller size (72 characters)
- ✅ No payload visible to client
- ✅ Direct scope access via introspection endpoint (RFC 7662)
**Disadvantages:**
- ❌ Higher latency - Requires HTTP call to introspection endpoint
- ❌ Slower than JWT signature verification (network roundtrip)
**Validation Method:**
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
- Token active status
- Scope claim (direct access, no inference needed)
- User information (`sub`, `username`)
- Token metadata (`exp`, `iat`, `client_id`)
Falls back to userinfo endpoint only if introspection is unavailable.
**When to Use:**
- Use **JWT tokens** for production (better performance, no HTTP call)
- Use **opaque tokens** for compatibility with clients that don't support JWT
---
## Scope-Based Authorization
### Scope Definitions
The MCP server uses **coarse-grained scopes** for simplicity:
| Scope | Operations | Examples |
|-------|------------|----------|
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
### Standard OIDC Scopes
| Scope | Description | Required |
|-------|-------------|----------|
| `openid` | OIDC authentication | Yes |
| `profile` | User profile information | Recommended |
| `email` | Email address | Recommended |
### Recommended Configurations
**Full Access:**
```
openid profile email mcp:notes:read mcp:notes:write
```
**Read-Only:**
```
openid profile email mcp:notes:read
```
**No Custom Scopes (OIDC only):**
```
openid profile email
```
### Implementation
All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("mcp:notes:read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires mcp:notes:read scope)"""
...
@mcp.tool()
@require_scopes("mcp:notes:write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires mcp:notes:write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
- ✅ 90/90 tools covered (100%)
### Dynamic Tool Filtering
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
**Token with `mcp:notes:read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**Token with `mcp:notes:write` only:**
- `list_tools()` returns 54 write-only tools
- Read tools are hidden from the tool list
**Token with both scopes:**
- `list_tools()` returns all 90 tools
**Token with no custom scopes:**
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
**BasicAuth mode:**
- `list_tools()` returns all 90 tools (no filtering)
**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering.
### Scope Challenges
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="mcp:notes:write",
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
```
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
### Protected Resource Metadata (PRM)
The server implements RFC 9728's Protected Resource Metadata endpoint:
**Endpoint:** `GET /.well-known/oauth-protected-resource/mcp`
**Response:**
```json
{
"resource": "http://localhost:8001/mcp",
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
"authorization_servers": ["http://localhost:8080"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
}
```
This allows OAuth clients to discover supported scopes before requesting authorization.
---
## Configuration
### Docker Services
The development environment includes two MCP server variants:
| Service | Port | Auth Type | Token Type | Use Case |
|---------|------|-----------|------------|----------|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
| `mcp-oauth` | 8001 | OAuth | JWT (configurable) | OAuth testing with JWT tokens |
### OAuth Service Configuration
The `mcp-oauth` service uses **Dynamic Client Registration (DCR)** by default and is configured to request JWT tokens:
**Default Configuration (DCR with JWT tokens):**
```yaml
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
ports:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
volumes:
- oauth-client-storage:/app/.oauth # Persist DCR credentials
```
**With Pre-Configured Credentials:**
```yaml
mcp-oauth:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
ports:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
```
**Key Points:**
- **No credentials needed** - DCR automatically registers the client on first start
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
- **Token verifier supports both** - Can handle JWT and opaque tokens
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
### Dynamic Client Registration (DCR)
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
**How It Works:**
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
```
1. Environment Variables (Highest Priority)
├─ NEXTCLOUD_OIDC_CLIENT_ID
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
2. Storage File (Second Priority)
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
3. Dynamic Client Registration (Automatic Fallback)
├─ Discovers registration endpoint from /.well-known/openid-configuration
├─ Registers new client with requested scopes and token type
├─ Saves credentials to storage file for future use
└─ Client credentials persist across restarts
```
**Configuration:**
DCR automatically configures the client based on environment variables:
```bash
# Minimal DCR configuration (no credentials needed!)
export NEXTCLOUD_HOST=http://localhost:8080
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
```
**Credential Storage:**
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
- File has restrictive permissions (0600 - owner read/write only)
- Credentials are reused on subsequent starts (no re-registration needed)
- Storage file is checked for expiration (auto-regenerates if expired)
**Format:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"client_id_issued_at": 1761097039,
"client_secret_expires_at": 2076457039,
"redirect_uris": ["http://localhost:8000/oauth/callback"]
}
```
**Benefits:**
- ✅ Zero-configuration OAuth setup
- ✅ Automatic credential management
- ✅ Supports both JWT and opaque tokens
- ✅ Credentials persist across container restarts
- ✅ Automatic re-registration if credentials expire
- ✅ Properly sets `allowed_scopes` for JWT token validation
### Manual Client Creation
Manual client creation is **optional** but may be preferred when:
- You want explicit control over client configuration
- You're deploying to production environments with strict security policies
- You need to pre-provision OAuth clients before deployment
**Create Client via OCC Command:**
```bash
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
"Nextcloud MCP Server" \
"http://localhost:8000/oauth/callback"
```
**Output:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"token_type": "jwt",
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
}
```
**Configure MCP Server with Pre-Configured Credentials:**
```bash
# Option 1: Environment variables (highest priority)
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
# Option 2: Storage file (second priority)
# Save the JSON response to .nextcloud_oauth_client.json
# Server will automatically load it on startup
```
When credentials are provided via environment variables or storage file, **DCR is skipped**.
---
## Architecture
### Component Overview
```
┌──────────────────┐ OAuth Flow ┌──────────────────┐
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
│ (Claude, etc) │ │ Server │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ JWT Access Token │
│ { │
│ "scope": "openid mcp:notes:read mcp:notes:write" │
│ ... │
│ } │
│ │
v │
┌────────────────────────────────────────────────────────────┐
│ Nextcloud MCP Server │
│ ┌───────────────────────────────────────────────────┐ │
│ │ NextcloudTokenVerifier │ │
│ │ - JWT signature verification (JWKS) │ │
│ │ - Introspection endpoint (opaque tokens) │ │
│ │ - Userinfo fallback (last resort) │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Dynamic Tool Filtering (list_tools) │ │
│ │ - Get user scopes from verified token │ │
│ │ - Filter tools based on @require_scopes metadata │ │
│ │ - Return only accessible tools │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Tool Execution (@require_scopes decorator) │ │
│ │ - Check token scopes before execution │ │
│ │ - Raise InsufficientScopeError if missing │ │
│ │ - Return 403 with WWW-Authenticate header │ │
│ └───────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
### Key Components
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
- **Three-tier validation strategy:**
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
- Scope extraction from token payload (JWT) or introspection response (opaque)
- Token caching with TTL to reduce repeated validations
- Supports both access token formats transparently
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
- `@require_scopes()` decorator for tools
- `get_required_scopes()` - Extract scope requirements from functions
- `has_required_scopes()` - Check if user has necessary scopes
- `InsufficientScopeError` exception for WWW-Authenticate challenges
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`)
- Overrides FastMCP's `list_tools()` method
- Filters based on user's OAuth token scopes (JWT and Bearer)
- Only active in OAuth mode
- Bypassed in BasicAuth mode
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
- `GET /.well-known/oauth-protected-resource/mcp`
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
- RFC 9728 compliant
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
- Catches `InsufficientScopeError`
- Returns 403 with `WWW-Authenticate` header
- Includes missing scopes and PRM endpoint URL
### Token Validation Flow
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
```
┌─────────────────────────────────────────────────────────┐
│ verify_token(token) │
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
└────────────────────────┬────────────────────────────────┘
├──> 1. Check cache (lines 106-109)
│ ├─ Hit: Return cached AccessToken
│ └─ Miss: Continue to validation
├──> 2. JWT Format Check (lines 112-124)
│ ├─ Token has 3 parts (header.payload.signature)?
│ │ └─ Yes: Attempt JWT verification
│ │ ├─ Verify signature with JWKS (RS256)
│ │ ├─ Validate issuer, expiration
│ │ ├─ Extract scopes from payload
│ │ └─ Success: Return AccessToken
│ └─ Fail/Not JWT: Continue to introspection
├──> 3. Introspection (lines 126-134)
│ ├─ POST to /apps/oidc/introspect
│ ├─ Authenticate with client credentials
│ ├─ Response contains:
│ │ • active: true/false
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
│ │ • sub, exp, iat, client_id
│ ├─ Extract scopes from response
│ └─ Success: Return AccessToken
└──> 4. Userinfo Fallback (lines 137-142)
├─ GET /apps/oidc/userinfo
├─ Bearer token in Authorization header
├─ Infer scopes from response claims
└─ Return AccessToken or None
```
**Validation Priorities:**
| Token Type | Method | Performance | Scope Access | Code Reference |
|------------|--------|-------------|--------------|----------------|
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
```python
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enables JWT verification
issuer=jwt_validation_issuer, # For JWT issuer validation
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
client_id=client_id, # Required for introspection auth
client_secret=client_secret, # Required for introspection auth
)
```
## Testing
### Test Infrastructure
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
**Test Files:**
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
- `tests/conftest.py` - Shared fixtures for JWT testing
### Consent Scenario Tests
Four test scenarios verify scope-based tool filtering with different consent levels:
#### 1. No Custom Scopes (0 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
```
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
#### 2. Read-Only Access (36 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
```
**Scenario:** JWT token with `mcp:notes:read` only
**Expected:** 36 read-only tools visible, write tools hidden
**Verifies:** Read tools accessible, write tools filtered out
#### 3. Write-Only Access (54 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
```
**Scenario:** JWT token with `mcp:notes:write` only
**Expected:** 54 write tools visible, read tools hidden
**Verifies:** Write tools accessible, read tools filtered out
#### 4. Full Access (90 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
```
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
**Expected:** All 90 tools visible
**Verifies:** Full access when user grants all custom scopes
### Test Fixtures
**OAuth Client Fixtures:**
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
- `full_access_oauth_client_credentials` - Client with both scopes
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
**Token Fixtures:**
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
- `playwright_oauth_token_full_access` - Obtains token with both scopes
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
**MCP Client Fixtures:**
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
### Running Tests
**All consent scenario tests:**
```bash
uv run pytest tests/server/test_scope_authorization.py -v
```
**JWT OAuth integration tests:**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
```
**With visible browser (debugging):**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
```
### Test Configuration
**Playwright Browser:**
- Default: Chromium
- Recommended for CI: Firefox (`--browser firefox`)
- Debugging: Add `--headed` flag
**OAuth Flow:**
- Uses automated Playwright browser automation
- Completes OAuth consent flow programmatically
- Creates separate OAuth client for each scenario
- Each user gets unique access token
---
## Troubleshooting
### Issue: JWT Issuer Validation Failed
**Symptom:**
```
WARNING JWT issuer validation failed: Invalid issuer
WARNING JWT verification failed, will try other methods
✅ Extracted scopes from access token: {'openid', 'profile'}
```
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
- Using `localhost` vs `127.0.0.1` inconsistently
- MCP server uses internal URL but clients use public URL
**Solution:**
```bash
# Option 1: Use consistent URLs
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Ensure all test fixtures also use localhost:8080
# Option 2: Check discovery document
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
```
**Impact if not fixed:**
- JWT validation falls back to userinfo endpoint
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
- Result: 0 tools visible or incorrect tool filtering
### Issue: Scopes Not Present in JWT
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
**Cause:** Client's `allowed_scopes` is empty or not configured
**Solution:**
```bash
# Check client configuration
docker compose exec app php occ oidc:list
# Look for allowed_scopes in output
# If empty, recreate client with --allowed_scopes
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
**Cause:** Server running in BasicAuth mode, not OAuth mode
**Solution:**
```bash
# Verify OAuth mode is active
docker compose logs mcp-oauth | grep "OAuth mode"
# Should see: "Running in OAuth mode"
# If not, check environment variables:
docker compose exec mcp-oauth env | grep NEXTCLOUD_OIDC
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
```
### Verifying DCR Scope Configuration
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
**To verify DCR scopes are working:**
```bash
# Check the registered client's allowed_scopes via database
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
```
**If scopes are missing:**
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
2. Check MCP server startup logs for the scopes being requested
3. Verify DCR is enabled in Nextcloud OIDC app settings
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
### Issue: Token Type Case Sensitivity
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
**Solution:** Always use lowercase:
```bash
# Correct
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# Incorrect (will generate opaque tokens)
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
```
### Issue: Missing WWW-Authenticate Header
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
**Cause:** Server not in OAuth mode, or exception not being caught
**Solution:**
```bash
# Check server logs for OAuth mode
docker compose logs mcp-oauth | grep "WWW-Authenticate scope challenges enabled"
# Should see this during startup
# Check exception handling
docker compose logs mcp-oauth | grep "InsufficientScopeError"
```
### Debugging Tools
**Check JWT contents:**
```bash
# Decode JWT (base64 decode the payload)
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
```
**Check database scopes:**
```bash
# View access tokens with scopes
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
# View user consents
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
```
**Check server logs:**
```bash
# Follow JWT verification logs
docker compose logs -f mcp-oauth | grep -E "JWT|scope|tool"
# Check for issuer mismatches
docker compose logs mcp-oauth | grep -i issuer
```
---
## Production Deployment
### Deployment Checklist
**Use JWT Tokens** - Enable `token_type=jwt` for better performance
**Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
**Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
**Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
**Secure Credentials** - Store client credentials securely (environment variables or secrets management)
**Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
**Enable Logging** - Configure appropriate log levels for JWT verification
### Production Configuration Example
```yaml
# docker-compose.yml (production)
mcp-oauth:
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
environment:
- NEXTCLOUD_HOST=https://nextcloud.example.com
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
ports:
- "8001:8001"
```
### Security Considerations
**Token Storage:**
- Never commit credentials to version control
- Use environment variables or secrets management
- Rotate client secrets periodically
**Scope Configuration:**
- Grant minimum necessary scopes to clients
- Use read-only tokens for AI assistants that don't need write access
- Review OAuth client list regularly
**Network Security:**
- Use HTTPS in production
- Ensure issuer URL matches public URL
- Configure proper CORS headers
### Monitoring
**Key Metrics:**
- JWT verification success/failure rate
- Scope challenge frequency (indicates clients with insufficient scopes)
- Token validation latency
- Tool execution by scope (identify unused scopes)
**Log Patterns:**
```bash
# Success
INFO JWT verified successfully for user: admin
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: mcp:notes:write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
2. **No Refresh Token Support** - Tokens must be reacquired when expired
### Future Enhancements
**Potential Improvements:**
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
- Resource-level filtering (apply to MCP resources, not just tools)
- Automatic scope discovery from decorated tools
- Admin UI for scope management
---
## References
### Standards
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
### Related Documentation
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
### External Resources
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
---
**Implementation Date:** 2025-10-21 to 2025-10-23
**Version:** 1.0.0
**Status:** ✅ Production Ready
-319
View File
@@ -1,319 +0,0 @@
# OAuth Architecture
This document explains how OAuth2/OIDC authentication works in the Nextcloud MCP Server implementation.
## Overview
The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting access to Nextcloud resources. It relies on Nextcloud's OIDC Identity Provider for user authentication and token validation.
## Architecture Diagram
```
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ │ │ │ │
│ MCP Client │ │ MCP Server │ │ Nextcloud │
│ (Claude, │ │ (Resource │ │ Instance │
│ etc.) │ │ Server) │ │ │
│ │ │ │ │ │
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ │ │
│ 1. Connect to MCP │ │
├─────────────────────────────────>│ │
│ │ │
│ 2. Return auth settings │ │
│ (issuer_url, scopes) │ │
│<─────────────────────────────────┤ │
│ │ │
│ │ │
│ 3. Start OAuth flow (with PKCE) │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/authorize │
│ │ │
│ 4. User authenticates in browser│ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ 5. Authorization code (redirect)│ │
│<─────────────────────────────────┤ │
│ │ │
│ 6. Exchange code for token │ │
├──────────────────────────────────┼────────────────────────────────────>│
│ │ /apps/oidc/token │
│ │ │
│ 7. Access token │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ │ │
│ 8. API request with Bearer token│ │
├─────────────────────────────────>│ │
│ Authorization: Bearer xxx │ │
│ │ │
│ │ 9. Validate token via userinfo │
│ ├────────────────────────────────────>│
│ │ /apps/oidc/userinfo │
│ │ │
│ │ 10. User info (token valid) │
│ │<────────────────────────────────────┤
│ │ │
│ │ 11. Nextcloud API request │
│ ├────────────────────────────────────>│
│ │ Authorization: Bearer xxx │
│ │ (Notes, Calendar, etc.) │
│ │ │
│ │ 12. API response │
│ │<────────────────────────────────────┤
│ │ │
│ 13. MCP tool response │ │
│<─────────────────────────────────┤ │
│ │ │
```
## Components
### 1. MCP Client
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
- Stores and sends access token with each request
- **Example**: Claude Desktop, Claude Code
### 2. MCP Server (Resource Server)
- **Role**: OAuth 2.0 Resource Server
- **Location**: This Nextcloud MCP Server implementation
- **Responsibilities**:
- Validates Bearer tokens by calling Nextcloud's userinfo endpoint
- Caches validated tokens (default: 1 hour TTL)
- Creates authenticated Nextcloud client instances per-user
- Enforces PKCE requirements (S256 code challenge method)
- Exposes Nextcloud functionality via MCP tools
**Key Files**:
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode detection and configuration
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation logic
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
### 3. Nextcloud OIDC Apps
#### a) `oidc` - OIDC Identity Provider
- **Role**: OAuth 2.0 Authorization Server
- **Location**: Nextcloud app (`apps/oidc`)
- **Endpoints**:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/apps/oidc/authorize` - Authorization endpoint
- `/apps/oidc/token` - Token endpoint
- `/apps/oidc/userinfo` - User info endpoint (token validation)
- `/apps/oidc/jwks` - JSON Web Key Set
- `/apps/oidc/register` - Dynamic client registration
**Configuration**:
```bash
# Enable dynamic client registration (optional)
# Settings → OIDC → "Allow dynamic client registration"
```
#### b) `user_oidc` - OpenID Connect User Backend
- **Role**: Bearer token validation middleware
- **Location**: Nextcloud app (`apps/user_oidc`)
- **Responsibilities**:
- Validates Bearer tokens for Nextcloud API requests
- Creates user sessions from valid Bearer tokens
- Integrates with Nextcloud's authentication system
**Configuration**:
```bash
# Enable Bearer token validation (required)
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
> [!IMPORTANT]
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints. See [Upstream Status](oauth-upstream-status.md) for details.
### 4. Nextcloud Instance
- **Role**: Resource Owner / API Provider
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
## Authentication Flow
### Phase 1: OAuth Authorization (Steps 1-7)
1. **Client Connects**: MCP client connects to MCP server
2. **Auth Settings**: MCP server returns OAuth settings:
```json
{
"issuer_url": "https://nextcloud.example.com",
"resource_server_url": "http://localhost:8000",
"required_scopes": ["openid", "profile"]
}
```
3. **OAuth Flow**: Client initiates OAuth flow with PKCE
- Generates `code_verifier` (random string)
- Calculates `code_challenge` = SHA256(code_verifier)
- Redirects user to `/apps/oidc/authorize` with `code_challenge`
4. **User Authentication**: User logs in to Nextcloud via browser
5. **Authorization Code**: Nextcloud redirects back with authorization code
6. **Token Exchange**: Client exchanges code for access token
- Sends `code` + `code_verifier` to `/apps/oidc/token`
- OIDC app validates PKCE challenge
7. **Access Token**: Client receives access token (JWT or opaque)
### Phase 2: API Access (Steps 8-13)
8. **API Request**: Client sends MCP request with Bearer token
9. **Token Validation**: MCP server validates token:
- Checks cache (1-hour TTL by default)
- If not cached, calls `/apps/oidc/userinfo` with Bearer token
- Extracts username from `sub` or `preferred_username` claim
10. **User Info**: Nextcloud returns user info if token is valid
11. **Nextcloud API Call**: MCP server calls Nextcloud API on behalf of user
- Creates `NextcloudClient` instance with Bearer token
- User-specific permissions apply
12. **API Response**: Nextcloud returns data
13. **MCP Response**: MCP server returns formatted response to client
## Token Validation
The MCP server validates tokens using the **userinfo endpoint approach**:
### Why Userinfo (vs JWT Validation)?
**Advantages**:
- Works with both JWT and opaque tokens
- No need to manage JWKS rotation
- Always up-to-date (respects token revocation)
- Simpler implementation
**Caching Strategy**:
- Validated tokens cached for 1 hour (configurable)
- Cache keyed by token string
- Expired tokens re-validated automatically
**Implementation**: See [`NextcloudTokenVerifier`](../nextcloud_mcp_server/auth/token_verifier.py)
## PKCE Requirement
The MCP server **requires** PKCE with S256 code challenge method:
1. Server validates OIDC discovery advertises PKCE support
2. Checks for `code_challenge_methods_supported` field
3. Verifies `S256` is included in supported methods
4. Logs error if PKCE not properly advertised
**Why PKCE?**:
- Required by MCP specification
- Protects against authorization code interception
- Essential for public clients (desktop apps, CLI tools)
**Implementation**: See [`validate_pkce_support()`](../nextcloud_mcp_server/app.py#L31-L93)
## Client Registration
The MCP server supports two client registration modes:
### Automatic Registration (Dynamic Client Registration)
```bash
# No client credentials needed
NEXTCLOUD_HOST=https://nextcloud.example.com
```
**How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to `.nextcloud_oauth_client.json`
4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
**Best for**: Development, testing, quick deployments
### Pre-configured Client
```bash
# Manual client registration via CLI
php occ oidc:create --name="MCP Server" --type=confidential --redirect-uri="http://localhost:8000/oauth/callback"
# Configure MCP server
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_OIDC_CLIENT_ID=abc123
NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789
```
**Best for**: Production, long-running deployments
## Per-User Client Instances
Each authenticated user gets their own `NextcloudClient` instance:
```python
# From MCP context (contains validated token)
client = get_client_from_context(ctx)
# Creates NextcloudClient with:
# - username: from token's 'sub' or 'preferred_username' claim
# - auth: BearerAuth(token)
```
**Benefits**:
- User-specific permissions
- Audit trail (actions appear from correct user)
- No shared credentials
- Multi-user support
**Implementation**: See [`get_client_from_context()`](../nextcloud_mcp_server/auth/context_helper.py)
## Security Considerations
### Token Storage
- MCP client stores access token
- MCP server does NOT store tokens (validates per-request)
- Token validation results cached in-memory only
### PKCE Protection
- Server validates PKCE is advertised
- Client MUST use PKCE with S256
- Protects against authorization code interception
### Scopes
- Required scopes: `openid`, `profile`
- Additional scopes inferred from userinfo response
### Token Validation
- Every MCP request validates Bearer token
- Cached for performance (1-hour default)
- Calls userinfo endpoint for validation
## Configuration
See [Configuration Guide](configuration.md) for all OAuth environment variables:
| Variable | Purpose |
|----------|---------|
| `NEXTCLOUD_HOST` | Nextcloud instance URL |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
## Testing
The integration test suite includes comprehensive OAuth testing:
- **Automated tests** (Playwright): [`tests/client/test_oauth_playwright.py`](../tests/client/test_oauth_playwright.py)
- **Fixtures**: [`tests/conftest.py`](../tests/conftest.py)
Run OAuth tests:
```bash
# Start OAuth-enabled MCP server
docker-compose up --build -d mcp-oauth
# Run automated tests
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
```
## See Also
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
- [Upstream Status](oauth-upstream-status.md) - Required upstream patches
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Authorization Framework
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
-545
View File
@@ -1,545 +0,0 @@
# OAuth Setup Guide
This guide walks you through setting up OAuth2/OIDC authentication for the Nextcloud MCP server in production.
> **Quick Start?** If you want a 5-minute setup for development, see [OAuth Quick Start](quickstart-oauth.md).
## Table of Contents
- [Prerequisites](#prerequisites)
- [Architecture Overview](#architecture-overview)
- [Step 1: Install Nextcloud Apps](#step-1-install-nextcloud-apps)
- [Step 2: Configure OIDC Apps](#step-2-configure-oidc-apps)
- [Step 3: Choose Deployment Mode](#step-3-choose-deployment-mode)
- [Step 4: Configure MCP Server](#step-4-configure-mcp-server)
- [Step 5: Start and Verify](#step-5-start-and-verify)
- [Testing Authentication](#testing-authentication)
- [Production Recommendations](#production-recommendations)
## Prerequisites
Before beginning, ensure you have:
- **Nextcloud instance** with administrator access
- **Nextcloud version** 28 or later
- **SSH/CLI access** to Nextcloud server (for `occ` commands)
- **Python 3.11+** installed on MCP server host
- **MCP server installed** (see [Installation Guide](installation.md))
## Architecture Overview
The OAuth implementation uses the following components:
```
MCP Client ←→ MCP Server (Resource Server) ←→ Nextcloud (Authorization Server + APIs)
OAuth Flow Bearer Token Auth
```
**Key Roles**:
- **MCP Server**: OAuth Resource Server (validates tokens, provides MCP tools)
- **Nextcloud `oidc` app**: OAuth Authorization Server (issues tokens)
- **Nextcloud `user_oidc` app**: Token validation middleware
For detailed architecture, see [OAuth Architecture](oauth-architecture.md).
## Step 1: Install Nextcloud Apps
OAuth authentication requires **two Nextcloud apps** to work together.
### Required Apps
#### 1. `oidc` - OIDC Identity Provider
**Purpose**: Makes Nextcloud an OAuth2/OIDC authorization server
**Installation**:
1. Open Nextcloud as administrator
2. Navigate to **Apps****Security**
3. Find **"OIDC"** (full name: "OIDC Identity Provider")
4. Click **Enable** or **Download and enable**
**Provides**:
- OAuth2 authorization endpoint
- Token endpoint
- User info endpoint
- JWKS endpoint
- Dynamic client registration endpoint (optional)
#### 2. `user_oidc` - OpenID Connect User Backend
**Purpose**: Authenticates users and validates Bearer tokens
**Installation**:
1. In **Apps****Security**
2. Find **"OpenID Connect user backend"** (app ID: `user_oidc`)
3. Click **Enable** or **Download and enable**
**Provides**:
- Bearer token validation against OIDC provider
- User authentication via OIDC
- Session management for authenticated users
> [!IMPORTANT]
> **Upstream Patch Required**: The `user_oidc` app needs a patch for Bearer token support with app-specific APIs (Notes, Calendar, etc.). The patch is pending upstream review.
>
> **Status**: See [Upstream Status](oauth-upstream-status.md) for current PR status and workarounds.
>
> **Impact**: OCS APIs work without patch, but app-specific APIs require the patch.
### Verify Installation
```bash
# Check both apps are installed and enabled
php occ app:list | grep -E "oidc|user_oidc"
# Expected output:
# - oidc: enabled
# - user_oidc: enabled
```
## Step 2: Configure OIDC Apps
### Configure `oidc` App (Identity Provider)
#### Option A: Dynamic Client Registration (Development)
**Best for**: Development, testing, auto-registration
1. Navigate to **Settings****OIDC** (Administration settings)
2. Enable **"Allow dynamic client registration"**
3. (Optional) Configure client expiration:
```bash
# Default: 3600 seconds (1 hour)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
#### Option B: Pre-configured Clients (Production)
**Best for**: Production, long-running deployments
Skip the dynamic registration setting. You'll manually register clients via CLI in Step 3.
### Configure `user_oidc` App (Token Validation)
**Required**: Enable Bearer token validation:
```bash
# SSH into Nextcloud server
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
This tells `user_oidc` to validate Bearer tokens against Nextcloud's OIDC Identity Provider.
### Verify OIDC Discovery
Test that OIDC discovery endpoint is accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
```
Expected response:
```json
{
"issuer": "https://your.nextcloud.instance.com",
"authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
"token_endpoint": "https://your.nextcloud.instance.com/apps/oidc/token",
"userinfo_endpoint": "https://your.nextcloud.instance.com/apps/oidc/userinfo",
"jwks_uri": "https://your.nextcloud.instance.com/apps/oidc/jwks",
"registration_endpoint": "https://your.nextcloud.instance.com/apps/oidc/register",
...
}
```
### PKCE Support
The MCP server **requires PKCE** (Proof Key for Code Exchange) with S256 code challenge method.
**Validation**: The MCP server automatically validates PKCE support at startup by checking the discovery response for `code_challenge_methods_supported`.
**Note**: If PKCE is not advertised in discovery metadata, the server logs a warning but continues (PKCE still works, it's just not advertised). See [Upstream Status](oauth-upstream-status.md) for tracking.
## Step 3: Choose Deployment Mode
You have two options for managing OAuth clients:
### Mode A: Automatic Registration (Dynamic Client Registration)
**Best for**: Development, testing, quick deployments
**How it works**:
- MCP server automatically registers an OAuth client on first startup
- Uses Nextcloud's dynamic client registration endpoint
- Saves credentials to `.nextcloud_oauth_client.json`
- Reuses stored credentials on subsequent restarts
- Re-registers automatically if credentials expire
**Pros**:
- Zero configuration required
- Quick setup
- Automatic credential management
**Cons**:
- Clients expire (default: 1 hour, configurable)
- Must have dynamic client registration enabled on Nextcloud
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
---
### Mode B: Pre-configured Client (Production)
**Best for**: Production, long-running deployments, stable environments
**How it works**:
- You manually register an OAuth client via Nextcloud CLI
- Provide client credentials to MCP server via environment variables
- Credentials don't expire
**Pros**:
- Credentials don't expire
- Stable for production
- More control over client configuration
- Better for audit trails
**Cons**:
- Requires manual setup
- Needs SSH/CLI access to Nextcloud server
**Setup**: Register a client via CLI:
```bash
# SSH into Nextcloud server
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Example output:
# Client ID: abc123xyz789
# Client Secret: secret456def012
# Save these credentials for Step 4
```
**Important**: Adjust `--redirect-uri` to match your MCP server URL:
- Local: `http://localhost:8000/oauth/callback`
- Remote: `http://your-server:8000/oauth/callback`
- Custom port: `http://your-server:PORT/oauth/callback`
The redirect URI **must** be:
```
{NEXTCLOUD_MCP_SERVER_URL}/oauth/callback
```
## Step 4: Configure MCP Server
Create or update your `.env` file with OAuth configuration.
### For Mode A (Automatic Registration)
```bash
# Copy sample if needed
cp env.sample .env
# Edit .env
cat > .env << 'EOF'
# Nextcloud Instance
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Leave EMPTY for OAuth mode (do not set USERNAME/PASSWORD)
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: MCP server URL (for OAuth callbacks)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Optional: Client storage path
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
EOF
```
### For Mode B (Pre-configured Client)
```bash
# Copy sample if needed
cp env.sample .env
# Edit .env
cat > .env << 'EOF'
# Nextcloud Instance
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# OAuth Client Credentials (from Step 3)
NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789
NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012
# MCP server URL (must match redirect URI)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
EOF
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of Nextcloud instance |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
See [Configuration Guide](configuration.md) for all options.
## Step 5: Start and Verify
### Load Environment Variables
```bash
# Load from .env file
export $(grep -v '^#' .env | xargs)
# Verify key variables are set
echo "NEXTCLOUD_HOST: $NEXTCLOUD_HOST"
echo "NEXTCLOUD_MCP_SERVER_URL: $NEXTCLOUD_MCP_SERVER_URL"
```
### Start MCP Server
```bash
# Start with OAuth mode
uv run nextcloud-mcp-server --oauth
# Or with custom options
uv run nextcloud-mcp-server --oauth --port 8000 --log-level info
```
### Verify Startup
Look for these success messages:
**For Mode A (Auto-registration)**:
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
✓ PKCE support validated: ['S256']
INFO OIDC discovery successful
INFO Attempting dynamic client registration...
INFO Dynamic client registration successful
INFO OAuth client ready: <client-id>...
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
**For Mode B (Pre-configured)**:
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO Performing OIDC discovery: https://your.nextcloud.instance.com/.well-known/openid-configuration
✓ PKCE support validated: ['S256']
INFO OIDC discovery successful
INFO Using pre-configured OAuth client: abc123xyz789
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
### Common Startup Issues
| Issue | Solution |
|-------|----------|
| "OAuth mode requires NEXTCLOUD_HOST" | Set `NEXTCLOUD_HOST` in `.env` |
| "OIDC discovery failed" | Verify Nextcloud URL and network connectivity |
| "Dynamic registration failed" | Enable dynamic registration in OIDC app settings |
| "PKCE validation failed" | See [Upstream Status](oauth-upstream-status.md) |
See [OAuth Troubleshooting](oauth-troubleshooting.md) for detailed solutions.
## Testing Authentication
### Test with MCP Inspector
The MCP Inspector provides a web UI for testing:
```bash
# In a new terminal
uv run mcp dev
# Opens browser at http://localhost:6272
```
In the MCP Inspector UI:
1. Enter server URL: `http://localhost:8000/mcp`
2. Click **Connect**
3. Complete OAuth flow in browser popup:
- Login to Nextcloud
- Authorize MCP server access
- Redirected back to MCP Inspector
4. Test tools:
- Try `nc_notes_create_note`
- Try `nc_notes_search_notes`
- Try `nc_calendar_list_events`
### Test from Command Line
```bash
# Get an OAuth token (you'll need to implement client flow or extract from browser)
TOKEN="your_access_token_here"
# Test OCS API (should work)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test Notes API (requires upstream patch)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
### Verify Token Validation
Check MCP server logs for token validation:
```bash
# Start server with debug logging
uv run nextcloud-mcp-server --oauth --log-level debug
# Look for:
# DEBUG Token validation via userinfo endpoint
# DEBUG Token validated successfully for user: username
```
## Production Recommendations
### Security Best Practices
1. **Use Pre-configured Clients** (Mode B)
- More stable
- Better audit trails
- No expiration issues
2. **Secure Credential Storage**
```bash
# Set restrictive permissions
chmod 600 .nextcloud_oauth_client.json
chmod 600 .env
```
3. **Use HTTPS for MCP Server**
- Especially important for remote access
- Use reverse proxy (nginx, Apache) with SSL
4. **Restrict Redirect URIs**
- Only register necessary redirect URIs
- Use specific URLs (not wildcards)
### Deployment Considerations
1. **MCP Server URL**
- Must be accessible to OAuth clients
- Must match redirect URI registered with Nextcloud
- For Docker: expose port and use correct host
2. **Network Configuration**
- MCP server must reach Nextcloud (OIDC endpoints)
- OAuth clients must reach MCP server (callbacks)
- OAuth clients must reach Nextcloud (authorization flow)
3. **Process Management**
- Use systemd, supervisord, or Docker for MCP server
- Ensure automatic restart on failure
- Monitor logs for OAuth errors
### Example Production Configs
#### Docker Compose
```yaml
version: '3'
services:
nextcloud-mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
ports:
- "127.0.0.1:8000:8000"
environment:
NEXTCLOUD_HOST: https://your.nextcloud.instance.com
NEXTCLOUD_OIDC_CLIENT_ID: ${NEXTCLOUD_OIDC_CLIENT_ID}
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
volumes:
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
command: ["--oauth", "--transport", "streamable-http"]
restart: unless-stopped
```
#### Systemd Service
```ini
[Unit]
Description=Nextcloud MCP Server (OAuth)
After=network.target
[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/nextcloud-mcp-server
Environment="NEXTCLOUD_HOST=https://your.nextcloud.instance.com"
Environment="NEXTCLOUD_OIDC_CLIENT_ID=abc123xyz789"
Environment="NEXTCLOUD_OIDC_CLIENT_SECRET=secret456def012"
Environment="NEXTCLOUD_MCP_SERVER_URL=http://your-server:8000"
ExecStart=/opt/nextcloud-mcp-server/.venv/bin/nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
### Monitoring and Maintenance
1. **Log Monitoring**
```bash
# Watch for OAuth errors
tail -f /var/log/nextcloud-mcp/server.log | grep -i "oauth\|token"
```
2. **Token Expiration** (Mode A only)
- Monitor for "Stored client has expired" messages
- Consider increasing expiration or switching to Mode B
3. **Upstream Patches**
- Subscribe to [Upstream Status](oauth-upstream-status.md)
- Plan to update when patches are merged
## Troubleshooting
For OAuth-specific issues, see [OAuth Troubleshooting](oauth-troubleshooting.md).
Common issues:
- [OIDC discovery failed](oauth-troubleshooting.md#oidc-discovery-failed)
- [Bearer token auth fails](oauth-troubleshooting.md#bearer-token-authentication-fails)
- [Client expired](oauth-troubleshooting.md#client-expired)
- [PKCE errors](oauth-troubleshooting.md#pkce-not-advertised)
## Next Steps
- [OAuth Architecture](oauth-architecture.md) - Understand how OAuth works
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Solve common issues
- [Upstream Status](oauth-upstream-status.md) - Track required patches
- [Configuration](configuration.md) - All environment variables
- [Running the Server](running.md) - Additional server options
## See Also
- [Authentication Overview](authentication.md) - OAuth vs BasicAuth comparison
- [Quick Start Guide](quickstart-oauth.md) - 5-minute setup for development
- [MCP Specification](https://spec.modelcontextprotocol.io/) - MCP protocol details
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) - OAuth 2.0 Framework
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) - PKCE Extension
-643
View File
@@ -1,643 +0,0 @@
# OAuth Troubleshooting
This guide covers OAuth-specific issues and solutions for the Nextcloud MCP server.
For general troubleshooting, see [Troubleshooting Guide](troubleshooting.md).
## Quick Diagnosis
Start here to identify your issue:
| Symptom | Likely Cause | Quick Fix Link |
|---------|--------------|----------------|
| "OAuth mode requires NEXTCLOUD_HOST" | Missing environment variable | [Missing NEXTCLOUD_HOST](#missing-nextcloud_host) |
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
## Configuration Issues
### Missing NEXTCLOUD_HOST
**Error Message**:
```
OAuth mode requires NEXTCLOUD_HOST environment variable
```
**Cause**: The `NEXTCLOUD_HOST` environment variable is not set or empty.
**Solution**:
1. Add to your `.env` file:
```bash
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
```
2. Reload environment variables:
```bash
export $(grep -v '^#' .env | xargs)
```
3. Verify it's set:
```bash
echo $NEXTCLOUD_HOST
# Should output: https://your.nextcloud.instance.com
```
---
### Missing or Misconfigured OIDC Apps
**Error Message**:
```
OAuth mode requires either client credentials OR dynamic client registration
```
**Cause**: The required Nextcloud OIDC apps are either:
- Not installed
- Not enabled
- Missing configuration
**Solution**:
**Step 1**: Verify both apps are installed:
```bash
# Check installed apps
php occ app:list | grep -E "oidc|user_oidc"
# Should show:
# - oidc: enabled
# - user_oidc: enabled
```
If not installed:
1. Open Nextcloud as administrator
2. Navigate to **Apps** → **Security**
3. Install **"OIDC"** (OIDC Identity Provider)
4. Install **"OpenID Connect user backend"** (user_oidc)
5. Enable both apps
**Step 2**: Enable dynamic client registration:
1. Go to **Settings** → **OIDC** (Administration)
2. Enable **"Allow dynamic client registration"**
**Step 3**: Configure Bearer token validation:
```bash
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
**Step 4**: Verify discovery endpoint:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
# Should output:
# "https://your.nextcloud.instance.com/apps/oidc/register"
```
**Alternative**: Use pre-configured client credentials:
```bash
# Register client via CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
echo "NEXTCLOUD_OIDC_CLIENT_ID=<client-id>" >> .env
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>" >> .env
```
---
### Client Expired
**Error Message**:
```
Stored client has expired
```
**Cause**: Dynamically registered OAuth clients expire (default: 1 hour).
**Solution**:
**Option 1: Restart the Server** (Automatic re-registration)
```bash
uv run nextcloud-mcp-server --oauth
# Server automatically re-registers if credentials expired
```
**Option 2: Use Pre-configured Credentials** (Recommended for production)
```bash
# Register permanent client via Nextcloud CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
```
Pre-configured clients don't expire.
**Option 3: Increase Expiration Time**
```bash
# Via Nextcloud CLI (default: 3600 seconds = 1 hour)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
---
### File Permission Error
**Error Message**:
```
Permission denied when reading/writing .nextcloud_oauth_client.json
```
**Cause**: The server cannot access the OAuth client storage file.
**Solution**:
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# If file doesn't exist, ensure directory is writable
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
For custom storage paths:
```bash
# Set custom path in .env
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
# Ensure directory exists and is writable
mkdir -p $(dirname /path/to/custom/oauth_client.json)
chmod 755 $(dirname /path/to/custom/oauth_client.json)
```
---
## Discovery and Connection Issues
### OIDC Discovery Failed
**Error Message**:
```
OIDC discovery failed
Cannot reach OIDC discovery endpoint
```
**Cause**: The server cannot reach the Nextcloud OIDC discovery endpoint.
**Solution**:
**Step 1**: Verify Nextcloud URL is correct:
```bash
echo $NEXTCLOUD_HOST
# Should be full URL: https://your.nextcloud.instance.com
```
**Step 2**: Test discovery endpoint manually:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Should return JSON with OIDC configuration
# {
# "issuer": "https://your.nextcloud.instance.com",
# "authorization_endpoint": "https://your.nextcloud.instance.com/apps/oidc/authorize",
# ...
# }
```
**Step 3**: Check network connectivity:
```bash
# Test basic connectivity
ping your.nextcloud.instance.com
# Test HTTPS
curl -I https://your.nextcloud.instance.com
```
**Step 4**: Verify both OIDC apps are enabled:
```bash
php occ app:list | grep -E "oidc|user_oidc"
```
**Step 5**: Check firewall rules (if using Docker):
```bash
# Check if MCP server can reach Nextcloud
docker exec nextcloud-mcp-server curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
---
## Authentication Issues
### Bearer Token Authentication Fails
**Error Message**:
```
HTTP 401 Unauthorized when calling Nextcloud APIs
```
**Symptoms**:
- OCS APIs work (`/ocs/v2.php/cloud/capabilities`)
- App APIs fail (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Cause**: The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints.
**Solution**: Apply the Bearer token patch to `user_oidc` app.
See [Upstream Status](oauth-upstream-status.md#1-bearer-token-support-for-non-ocs-endpoints) for details.
**Quick Patch**:
```bash
# SSH into Nextcloud server
cd /path/to/nextcloud/apps/user_oidc
# Edit lib/User/Backend.php
# Add this line before each return statement in getCurrentUserId() method:
$this->session->set('app_api', true);
# Lines to modify: ~243, ~310, ~315, ~337
```
**Test the fix**:
```bash
# Get an OAuth token (from MCP client or test)
TOKEN="your_access_token"
# Test Notes API
curl -H "Authorization: Bearer $TOKEN" \
https://your.nextcloud.instance.com/apps/notes/api/v1/notes
# Should return notes JSON (not 401)
```
---
### PKCE Not Advertised
**Error Message**:
```
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
⚠️ MCP clients (like Claude Code) WILL REJECT this provider!
```
**Cause**: The OIDC discovery endpoint doesn't include `code_challenge_methods_supported` field.
**Impact**:
- Some MCP clients may refuse to connect
- Standards compliance issue (RFC 8414)
- **Functionality still works** (PKCE is accepted, just not advertised)
**Solution**:
**Short-term**: The MCP server logs a warning but continues. OAuth flow still works.
**Long-term**: Update the `oidc` app to advertise PKCE support.
See [Upstream Status](oauth-upstream-status.md#2-pkce-support-advertisement-in-discovery) for tracking.
**Verify**:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.code_challenge_methods_supported'
# Should return:
# ["S256", "plain"]
# If null, PKCE isn't advertised (but still works)
```
---
## Runtime Issues
### MCP Client Can't Authenticate
**Symptoms**:
- Client connects but OAuth flow fails
- Authorization redirects don't work
- Token exchange fails
**Diagnosis**:
**Step 1**: Verify OAuth is configured correctly:
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
```
Look for:
```
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
**Step 2**: Check OIDC discovery:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
**Step 3**: Verify MCP server URL matches client expectations:
```bash
echo $NEXTCLOUD_MCP_SERVER_URL
# Should match the URL clients use to connect
# Default: http://localhost:8000
```
If MCP server is on a different host/port, update:
```bash
NEXTCLOUD_MCP_SERVER_URL=http://actual-host:actual-port
```
**Step 4**: Check redirect URI configuration:
For pre-configured clients, ensure redirect URI matches:
```bash
# Client redirect URI should be:
http://your-mcp-server-url/oauth/callback
# Example for local server:
http://localhost:8000/oauth/callback
```
---
### Tools Return 401 Errors
**Symptoms**:
- OAuth flow completes successfully
- Token is valid
- MCP tools return 401 errors
**Cause**: Bearer token not working with Nextcloud APIs.
**Solution**: See [Bearer Token Authentication Fails](#bearer-token-authentication-fails) above.
---
### Limited Scopes - Only Seeing Notes Tools
**Symptoms**:
- MCP client (e.g., Claude Code) successfully connects via OAuth
- Only Notes tools are available (7 tools instead of 90+)
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
**Diagnosis**:
Check what scopes the client has been granted:
```bash
# View registered clients and their allowed scopes
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, allowed_scopes}'
```
Look for the client's `allowed_scopes` field. If it's empty or only contains notes scopes, that's the issue.
**Solution**:
**Option 1: Delete Client and Reconnect** (Recommended for MCP clients)
```bash
# Find the client ID
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, client_id}'
# Delete the client
php occ oidc:delete <client_id>
# Reconnect from Claude Code
# This will trigger a new OAuth flow where you can grant all scopes
```
When reconnecting, you'll see a consent screen listing all available scopes. Make sure to approve all the scopes you want the client to access.
**Option 2: Update Client Scopes via CLI**
```bash
# Update allowed scopes for an existing client
php occ oidc:update <client_id> \
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
# User will need to reconnect to get new token with updated scopes
```
**Verify Available Scopes**:
Check what scopes the MCP server advertises:
```bash
curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_supported'
# Should show all 16 scope categories:
# - openid
# - mcp:notes:read, mcp:notes:write
# - mcp:calendar:read, mcp:calendar:write
# - mcp:contacts:read, mcp:contacts:write
# - mcp:cookbook:read, mcp:cookbook:write
# - mcp:deck:read, mcp:deck:write
# - mcp:tables:read, mcp:tables:write
# - mcp:files:read, mcp:files:write
# - mcp:sharing:read, mcp:sharing:write
```
**Understanding Scope Filtering**:
The MCP server dynamically filters tools based on the scopes in your access token:
- Check server logs for: `✂️ JWT scope filtering: X/90 tools available for scopes: {...}`
- This shows how many tools are visible vs total available
- Each tool requires specific scopes (read and/or write)
**Available Scope Categories**:
| Scope Prefix | Nextcloud App | Read Operations | Write Operations |
|--------------|---------------|-----------------|------------------|
| `mcp:notes:*` | Notes | Get, search, list | Create, update, delete, append |
| `mcp:calendar:*` | Calendar (CalDAV) | Get events, todos, calendars | Create/update/delete events, todos |
| `mcp:contacts:*` | Contacts (CardDAV) | Get contacts, address books | Create/update/delete contacts |
| `mcp:cookbook:*` | Cookbook | Get recipes, search | Create/update recipes |
| `mcp:deck:*` | Deck | Get boards, cards | Create/update boards, cards |
| `mcp:tables:*` | Tables | Get rows, tables | Create/update/delete rows |
| `mcp:files:*` | Files (WebDAV) | List, read files | Upload, delete, move files |
| `mcp:sharing:*` | Sharing | Get shares | Create/update shares |
---
## Switching Authentication Modes
### From BasicAuth to OAuth
```bash
# 1. Remove or comment out USERNAME/PASSWORD in .env
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
# 2. Ensure NEXTCLOUD_HOST is set
grep NEXTCLOUD_HOST .env
# 3. Restart server with OAuth
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --oauth
```
### From OAuth to BasicAuth
```bash
# 1. Add USERNAME/PASSWORD to .env
echo "NEXTCLOUD_USERNAME=your-username" >> .env
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
# 2. Restart server (BasicAuth auto-detected)
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --no-oauth
```
---
## Advanced Debugging
### Enable Debug Logging
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
```
Look for:
- OIDC discovery details
- Client registration attempts
- Token validation logs
- API request/response details
### Test Discovery Endpoint
```bash
# Full discovery response
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq
# Check specific fields
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '{
issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
registration_endpoint,
code_challenge_methods_supported
}'
```
### Test Token Validation
```bash
# Get userinfo with token
curl -H "Authorization: Bearer $TOKEN" \
https://your.nextcloud.instance.com/apps/oidc/userinfo
# Should return user info:
# {
# "sub": "username",
# "preferred_username": "username",
# "name": "Display Name",
# ...
# }
```
### Test Nextcloud API Access
```bash
# Test OCS API (should work)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test app API (requires patch)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
---
## Getting Help
If you continue to experience issues:
### 1. Collect Diagnostic Information
```bash
# Server version
uv run nextcloud-mcp-server --version
# Python version
python3 --version
# Server logs with debug
uv run nextcloud-mcp-server --oauth --log-level debug 2>&1 | tee mcp-server.log
# OIDC discovery
curl https://your.nextcloud.instance.com/.well-known/openid-configuration > oidc-discovery.json
# Nextcloud version
# Check in Nextcloud admin panel or:
php occ -V
```
### 2. Check Documentation
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
- [OAuth Setup Guide](oauth-setup.md) - Configuration steps
- [Upstream Status](oauth-upstream-status.md) - Required patches
- [Configuration](configuration.md) - Environment variables
### 3. Open an Issue
If problems persist, [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
- **Error messages** (full text)
- **Server logs** (with `--log-level debug`)
- **OIDC discovery response** (from curl command above)
- **Nextcloud version**
- **OIDC app versions** (`oidc` and `user_oidc`)
- **Steps to reproduce**
- **Environment details** (OS, Python version, Docker vs local)
---
## See Also
- [OAuth Quick Start](quickstart-oauth.md) - Get started quickly
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration
- [OAuth Architecture](oauth-architecture.md) - Technical details
- [Upstream Status](oauth-upstream-status.md) - Required patches
- [General Troubleshooting](troubleshooting.md) - Non-OAuth issues
-242
View File
@@ -1,242 +0,0 @@
# OAuth Upstream Status
This document tracks the status of upstream patches and pull requests required for full OAuth functionality.
## Overview
The Nextcloud MCP Server's OAuth implementation relies on two Nextcloud apps:
- **`oidc`** - OIDC Identity Provider (Authorization Server)
- **`user_oidc`** - OpenID Connect user backend (Token validation)
While the core OAuth flow works, there are **pending upstream improvements** that enhance functionality and standards compliance.
## Required Patches
### 1. Bearer Token Support for Non-OCS Endpoints
**Status**: 🟡 **Patch Required** (Pending Upstream)
**Affected Component**: `user_oidc` app
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
**Impact**:
-**Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
-**Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
**Patch Summary**:
```php
// Add before successful Bearer token authentication returns
$this->session->set('app_api', true);
```
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
---
### 2. PKCE Support (RFC 7636)
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
**Authorization Endpoint** (`/authorize`):
- Accepts `code_challenge` and `code_challenge_method` parameters
- Validates code_challenge format (43-128 characters, unreserved chars only)
- Supports both `S256` (SHA-256) and `plain` challenge methods
- Stores challenge and method in database for later verification
**Token Endpoint** (`/token`):
- Accepts `code_verifier` parameter
- Verifies code_verifier against stored code_challenge using proper algorithm
- Uses constant-time comparison to prevent timing attacks
- Enforces code_verifier requirement when PKCE was used in authorization
**Discovery Document**:
```json
{
"code_challenge_methods_supported": ["S256", "plain"]
}
```
**Database**:
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
- Migration included for existing installations
**Why It Mattered**:
- MCP specification requires PKCE with S256 code challenge method
- RFC 7636 PKCE provides security for public clients (no client secret)
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
- Prevents authorization code interception attacks
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
- **Changes**: Complete PKCE implementation (+194 lines)
- Authorization flow with code_challenge validation
- Token exchange with code_verifier verification
- Database schema updates
- Discovery document updates
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
## Upstream PRs Status
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow**:
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
- Dynamic client registration
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- Userinfo endpoint
**MCP Server as Resource Server**:
- Token validation via userinfo
- Per-user client instances
- Token caching
**Nextcloud OCS APIs**:
- Capabilities endpoint
- All OCS-based APIs
## What Requires Patches
The following functionality requires upstream patches:
🟡 **App-Specific APIs** (Requires user_oidc#1221):
- Notes API (`/apps/notes/api/`)
- Calendar API (CalDAV)
- Contacts API (CardDAV)
- Deck API
- Tables API
- Custom app APIs
**Standards Compliance**: Now complete with `oidc` app v1.10.0+
- ✅ Full RFC 8414 compliance (PKCE advertisement)
- ✅ MCP client compatibility guarantee
## Installation Instructions
### For Development/Testing
If the upstream PRs are not yet merged, you can apply patches manually:
#### 1. Apply Bearer Token Patch
```bash
# SSH into Nextcloud server
cd /path/to/nextcloud/apps/user_oidc
# Download and apply patch
# (Patch file to be created once PR is ready)
wget https://github.com/nextcloud/user_oidc/pull/XXXX.patch
git apply XXXX.patch
# Or manually edit lib/User/Backend.php
# Add this line before each return statement in getCurrentUserId():
# $this->session->set('app_api', true);
```
#### 2. Verify Installation
```bash
# Test with OAuth token
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your.nextcloud.com/apps/notes/api/v1/notes
# Should return notes JSON (not 401)
```
### For Production
**Recommendation**: Wait for upstream PRs to be merged and included in official Nextcloud releases before deploying OAuth in production.
**Alternative**: Use a patched version of `user_oidc` app in your deployment:
1. Fork the `user_oidc` app
2. Apply the required patches
3. Install your patched version
4. Document the changes for your team
## Testing
The integration test suite validates OAuth functionality:
```bash
# Start OAuth-enabled MCP server
docker-compose up --build -d mcp-oauth
# Run comprehensive OAuth tests
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
# Tests verify:
# - OAuth flow completion
# - Token validation
# - MCP tool calls with Bearer tokens
# - Notes API access (requires patch)
```
## Monitoring Upstream Progress
To track progress on these issues:
1. **Watch the upstream repositories**:
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
2. **Subscribe to specific issues**:
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
3. **Check Nextcloud release notes** for mentions of:
- Bearer token authentication improvements
- OIDC/OAuth enhancements
- AppAPI compatibility
## Contributing
Want to help get these patches merged?
1. **Test the patches**: Run the integration tests and report results
2. **Review PRs**: Provide feedback on upstream pull requests
3. **Document issues**: Report any problems or edge cases
4. **Contribute code**: Submit improvements or fixes to upstream
## Timeline Expectations
**Best Case**: PRs merged in next Nextcloud minor release (est. 3-6 months)
**Realistic**: PRs reviewed and merged within 6-12 months
**Meanwhile**: Use the workarounds documented in this guide
## See Also
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in this implementation
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
- [OAuth Setup Guide](oauth-setup.md) - Configuration instructions
---
**Last Updated**: 2025-10-20
**Next Review**: When issue #1221 (Bearer token support) has activity
-163
View File
@@ -1,163 +0,0 @@
# OAuth Quick Start Guide
Get up and running with OAuth authentication in 5 minutes.
## Prerequisites Checklist
Before you begin, ensure you have:
- [ ] Nextcloud instance with **administrator access**
- [ ] Nextcloud version 28 or later
- [ ] Python 3.11+ installed
- [ ] `uv` package manager installed ([installation instructions](https://docs.astral.sh/uv/getting-started/installation/))
## Step 1: Install Nextcloud Apps
Install **both** required apps in your Nextcloud instance:
1. Open Nextcloud as administrator
2. Navigate to **Apps****Security**
3. Install:
- **OIDC** (OIDC Identity Provider app)
- **OpenID Connect user backend** (user_oidc app)
4. Enable both apps
> [!IMPORTANT]
> The `user_oidc` app requires an upstream patch for Bearer token support. See [Upstream Status](oauth-upstream-status.md) for details. The functionality works, but the PR is pending.
## Step 2: Configure Nextcloud OIDC
Enable dynamic client registration and Bearer token validation:
### Via Web UI
1. Go to **Settings****OIDC** (Administration settings)
2. Enable **"Allow dynamic client registration"**
### Via CLI (Required)
SSH into your Nextcloud server and run:
```bash
# Enable Bearer token validation
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
## Step 3: Install MCP Server
Clone and install the MCP server:
```bash
# Clone repository
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server
# Install dependencies
uv sync
```
## Step 4: Configure Environment
Create a `.env` file with minimal configuration:
```bash
# Copy sample
cp env.sample .env
# Edit .env and set:
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# IMPORTANT: Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
```
## Step 5: Start the Server
Load environment variables and start the server:
```bash
# Load environment
export $(grep -v '^#' .env | xargs)
# Start server with OAuth
uv run nextcloud-mcp-server --oauth
```
Look for this success message:
```
✓ PKCE support validated: ['S256']
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
## Step 6: Test with MCP Inspector
Open a new terminal and test the connection:
```bash
# Start MCP Inspector
uv run mcp dev
```
This opens your browser. In the MCP Inspector UI:
1. Enter server URL: `http://127.0.0.1:8000/mcp`
2. Click **Connect**
3. Complete the OAuth flow in the browser popup
4. After authorization, you'll see available tools and resources
Test a tool by trying:
- **Tool**: `nc_notes_create_note`
- **Title**: "Test Note"
- **Content**: "Hello from MCP!"
- **Category**: "Notes"
## Troubleshooting Quick Fixes
### PKCE Error
If you see:
```
ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement
```
**Fix**: The Nextcloud OIDC app needs to be updated to advertise PKCE support. See [Upstream Status](oauth-upstream-status.md) for the required PR.
### 401 Unauthorized for Notes API
If OAuth works but Notes API returns 401:
**Fix**: The `user_oidc` app needs the Bearer token patch. See [Upstream Status](oauth-upstream-status.md) for details.
### Can't Reach OIDC Discovery Endpoint
**Fix**: Verify your Nextcloud URL is correct and accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
## Next Steps
- [OAuth Setup Guide](oauth-setup.md) - Detailed configuration options
- [OAuth Architecture](oauth-architecture.md) - How it works under the hood
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common issues and solutions
- [Configuration](configuration.md) - All environment variables
## Development vs Production
This quick start uses **automatic client registration** which is perfect for:
- Development
- Testing
- Quick deployments
For **production deployments**, consider:
1. Pre-registering OAuth client manually
2. Using dedicated client credentials that don't expire
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
---
**Need help?** Check [OAuth Troubleshooting](oauth-troubleshooting.md) or [open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues).
-440
View File
@@ -1,440 +0,0 @@
# Running the Server
This guide covers different ways to start and run the Nextcloud MCP server.
## Prerequisites
Before running the server:
1. **Install the server** - See [Installation Guide](installation.md)
2. **Configure environment** - See [Configuration Guide](configuration.md)
3. **Set up authentication** - See [OAuth Setup](oauth-setup.md) or [Authentication](authentication.md)
---
## Quick Start
Load your environment variables and start the server:
```bash
# Load environment variables from .env
export $(grep -v '^#' .env | xargs)
# Start the server
uv run nextcloud-mcp-server
```
The server will start on `http://127.0.0.1:8000` by default.
---
## Running Locally
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
The CLI provides a simple interface with built-in defaults:
#### OAuth Mode
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
uv run nextcloud-mcp-server
# Explicitly force OAuth mode
uv run nextcloud-mcp-server --oauth
# OAuth with custom host and port
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
# OAuth with pre-configured client
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789
# OAuth with specific apps only
uv run nextcloud-mcp-server --oauth \
--enable-app notes \
--enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
uv run nextcloud-mcp-server
# Explicitly force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
# BasicAuth with specific apps
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app webdav
```
### Method 2: Using uvicorn
For more control over server options (workers, reload, etc.):
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run with uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--host 127.0.0.1 \
--port 8000 \
--reload # Enable auto-reload for development
```
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
### Method 3: Using Python Module
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run as Python module
python -m nextcloud_mcp_server.app --oauth --port 8000
```
---
## Running with Docker
### Basic Docker Run
```bash
# OAuth mode
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# BasicAuth mode
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Docker with Persistent OAuth Storage
```bash
docker run -p 127.0.0.1:8000:8000 --env-file .env \
-v $(pwd)/.oauth:/app/.oauth \
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
### Docker Compose
Create `docker-compose.yml`:
```yaml
version: '3.8'
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
command: --oauth --enable-app notes --enable-app calendar
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
- ./oauth-storage:/app/.oauth
restart: unless-stopped
```
Start the service:
```bash
# Start in foreground
docker-compose up
# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the service
docker-compose down
```
---
## Server Options
### Host and Port
```bash
# Bind to all interfaces (accessible from network)
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
# Bind to localhost only (default, more secure)
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
# Use a different port
uv run nextcloud-mcp-server --port 8080
```
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
### Transport Protocols
The server supports multiple MCP transport protocols:
```bash
# Streamable HTTP (recommended)
uv run nextcloud-mcp-server --transport streamable-http
# SSE - Server-Sent Events (default, deprecated)
uv run nextcloud-mcp-server --transport sse
# HTTP
uv run nextcloud-mcp-server --transport http
```
> [!WARNING]
> SSE transport is deprecated and will be removed in a future version of the MCP spec. Please migrate to `streamable-http`.
### Logging
```bash
# Set log level (critical, error, warning, info, debug, trace)
uv run nextcloud-mcp-server --log-level debug
# Production: use warning or error
uv run nextcloud-mcp-server --log-level warning
```
### Selective App Enablement
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
```bash
# Available apps: notes, tables, webdav, calendar, contacts, deck
# Enable all apps (default)
uv run nextcloud-mcp-server
# Enable only Notes
uv run nextcloud-mcp-server --enable-app notes
# Enable multiple apps
uv run nextcloud-mcp-server \
--enable-app notes \
--enable-app calendar \
--enable-app contacts
# Enable only WebDAV for file operations
uv run nextcloud-mcp-server --enable-app webdav
```
**Use cases:**
- Reduce memory usage and startup time
- Limit functionality for security/organizational reasons
- Test specific app integrations
- Run lightweight instances with only needed features
---
## Development Mode
For active development with auto-reload:
```bash
# Using uvicorn with reload
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--reload \
--host 127.0.0.1 \
--port 8000 \
--log-level debug
```
Or use the CLI with reload flag:
```bash
uv run nextcloud-mcp-server --reload --log-level debug
```
---
## Connecting to the Server
### Using MCP Inspector
MCP Inspector is a browser-based tool for testing MCP servers:
```bash
# Start MCP Inspector
uv run mcp dev
# In the browser:
# 1. Enter server URL: http://localhost:8000
# 2. Complete OAuth flow (if using OAuth)
# 3. Explore tools and resources
```
### Using MCP Clients
MCP clients (like Claude Desktop, LLM IDEs) can connect to your server:
1. Configure the client with your server URL
2. Complete OAuth authentication (if enabled)
3. Start interacting with Nextcloud through the LLM
---
## Verifying Server Status
### Check Server Health
```bash
# Test if server is responding
curl http://localhost:8000/health
# Expected response: HTTP 200 OK
```
### Check OAuth Configuration
Look for these log messages on startup:
**OAuth mode:**
```
INFO OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)
INFO Configuring MCP server for OAuth mode
INFO OIDC discovery successful
INFO OAuth client ready: <client-id>...
INFO OAuth initialization complete
```
**BasicAuth mode:**
```
INFO BasicAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD set)
INFO Initializing Nextcloud client with BasicAuth
```
---
## Process Management
### Running as a Background Service
#### Using systemd (Linux)
Create `/etc/systemd/system/nextcloud-mcp.service`:
```ini
[Unit]
Description=Nextcloud MCP Server
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/nextcloud-mcp-server
EnvironmentFile=/path/to/.env
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable nextcloud-mcp
sudo systemctl start nextcloud-mcp
sudo systemctl status nextcloud-mcp
```
#### Using Docker Compose
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
### Monitoring Logs
```bash
# Local installation with systemd
sudo journalctl -u nextcloud-mcp -f
# Docker
docker logs -f <container-name>
# Docker Compose
docker-compose logs -f mcp
```
---
## Performance Tuning
### Multiple Workers
For production deployments with higher load:
```bash
# Using CLI (if supported)
uv run nextcloud-mcp-server --workers 4
# Using uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--workers 4 \
--host 0.0.0.0 \
--port 8000
```
### Production Settings
```bash
# Recommended production configuration
uv run nextcloud-mcp-server \
--oauth \
--host 127.0.0.1 \
--port 8000 \
--log-level warning \
--transport streamable-http \
--workers 2
```
---
## Troubleshooting
### Server won't start
Check logs for errors:
```bash
uv run nextcloud-mcp-server --log-level debug
```
Common issues:
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
- Port already in use - Try a different port with `--port`
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
### Can't connect to server
1. Verify server is running: `curl http://localhost:8000/health`
2. Check firewall settings
3. Verify host binding (use `0.0.0.0` to allow network access)
4. Check OAuth authentication if enabled
### OAuth authentication fails
See [Troubleshooting OAuth](troubleshooting.md) for detailed OAuth troubleshooting.
---
## See Also
- [Configuration Guide](configuration.md) - Environment variables
- [OAuth Setup](oauth-setup.md) - OAuth authentication setup
- [Troubleshooting](troubleshooting.md) - Common issues and solutions
- [Installation](installation.md) - Installing the server
@@ -1,317 +0,0 @@
# Testing Client Sessions Architecture
## Overview
This document compares different approaches to managing MCP client sessions in integration tests, addressing the fundamental incompatibility between pytest-asyncio's fixture management and anyio's structured concurrency requirements.
## The Problem
When using pytest-asyncio with anyio-based libraries (like the MCP Python SDK), session-scoped async generator fixtures encounter a fundamental issue:
1. **pytest-asyncio** runs fixture teardown in a **new asyncio task** using `runner.run()`
2. **anyio** requires that cancel scopes be entered and exited in the **same task**
3. This causes `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`
This is a **known limitation** documented in the anyio project and is not a bug in either pytest-asyncio or anyio, but rather an inherent incompatibility between their design philosophies.
## Solution Comparison
### Solution 1: Native Async Context Managers with Surgical Exception Handling ✅ **IMPLEMENTED**
**Approach**: Use native `async with` statements for clean code structure, but add targeted exception handling at the pytest fixture level to handle the expected teardown errors.
**Implementation**:
```python
async def create_mcp_client_session(
url: str,
token: str | None = None,
client_name: str = "MCP",
) -> AsyncGenerator[ClientSession, Any]:
"""Uses native async context managers for clean LIFO cleanup."""
headers = {"Authorization": f"Bearer {token}"} if token else None
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
try:
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp", client_name="Basic MCP"
):
yield session
except RuntimeError as e:
# Only catch the specific expected error during pytest teardown
if "cancel scope" in str(e) and "different task" in str(e):
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
else:
# Unexpected RuntimeError - re-raise to fail the test
raise
```
**Pros**:
- ✅ Clean, idiomatic code using native Python context managers
- ✅ Exception handling is surgical - only catches the specific expected error
- ✅ Unexpected errors still propagate and fail tests
- ✅ Can use session-scoped fixtures for performance
- ✅ Easy to understand and maintain
- ✅ Minimal code changes from original implementation
- ✅ No external dependencies required
**Cons**:
- ⚠️ Still requires exception suppression (though targeted)
- ⚠️ String-based exception matching is somewhat fragile
- ⚠️ Must apply the pattern to each session-scoped fixture
- ⚠️ Doesn't solve the root cause
**Verdict**: **Recommended** - Best balance of code clarity, maintainability, and pragmatism.
---
### Solution 2: Task-Isolated Fixtures
**Approach**: Run each fixture's client session in an isolated anyio task group, allowing independent cleanup without cross-fixture interference.
**Implementation**:
```python
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Fixture with task isolation for clean teardown."""
import anyio
session_holder = {"session": None}
async def create_and_hold_session():
"""Runs in isolated task - creates session and keeps it alive."""
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
session_holder["session"] = session
# Keep session alive until cancelled
try:
await anyio.sleep_forever()
except anyio.get_cancelled_exc_class():
pass # Expected cancellation
async with anyio.create_task_group() as tg:
tg.start_soon(create_and_hold_session)
# Wait for session to be ready
while session_holder["session"] is None:
await anyio.sleep(0.1)
yield session_holder["session"]
# Task group cancellation ensures clean LIFO cleanup
tg.cancel_scope.cancel()
```
**Pros**:
- ✅ No exception suppression needed
- ✅ Each fixture has its own isolated task scope
- ✅ More theoretically correct approach
- ✅ Can use session-scoped fixtures
**Cons**:
- ❌ Significantly more complex code
- ❌ Harder to understand for developers unfamiliar with anyio
- ❌ Requires understanding of task groups and cancel scopes
- ❌ More boilerplate per fixture
- ❌ Still doesn't solve the fundamental pytest-asyncio incompatibility
- ❌ Polling for session readiness is inelegant
- ❌ Higher cognitive overhead for maintenance
**Verdict**: **Not Recommended** - Complexity outweighs benefits. Consider only if exception handling is completely unacceptable.
---
### Solution 3: Function-Scoped Fixtures with Nested Context Managers
**Approach**: Change fixtures to function scope and rely on Python's context manager nesting for guaranteed LIFO cleanup.
**Implementation**:
```python
@pytest.fixture(scope="function") # Changed from session
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
"""Function-scoped fixture with natural LIFO cleanup."""
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
# For tests needing multiple clients:
@pytest.fixture(scope="function")
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
async with streamablehttp_client("http://localhost:8000/mcp") as (read1, write1, _):
async with ClientSession(read1, write1) as session1:
await session1.initialize()
async with streamablehttp_client("http://localhost:8001/mcp") as (read2, write2, _):
async with ClientSession(read2, write2) as session2:
await session2.initialize()
yield session1, session2
# Cleanup: session2 -> stream2 -> session1 -> stream1 (LIFO guaranteed)
```
**Pros**:
- ✅ No exception handling needed
- ✅ Simplest to understand
- ✅ Natural LIFO cleanup through Python's context managers
- ✅ Each test gets fresh clients (better isolation)
- ✅ No workarounds or hacks required
**Cons**:
- ❌ Significantly slower tests (new clients per test)
- ❌ Cannot share client state across tests
- ❌ More resource intensive
- ❌ Higher overhead for test suite execution
- ❌ May not be practical for expensive fixtures (e.g., OAuth tokens)
- ❌ Nested context managers become unwieldy with many clients
**Verdict**: **Good Alternative** - Consider for specific fixtures where session scope isn't critical, or for new test files where performance isn't a concern.
---
### Solution 4: Use pytest-trio Instead of pytest-asyncio (Future)
**Approach**: Replace pytest-asyncio with pytest-trio, which was designed with structured concurrency in mind.
**Implementation**:
```python
# pyproject.toml
[tool.pytest.ini_options]
# Remove: asyncio_mode = "auto"
# Add: trio_mode = "auto"
# Fixtures work naturally with trio
@pytest.fixture(scope="session")
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
```
**Pros**:
- ✅ No workarounds needed
- ✅ Designed for structured concurrency
- ✅ Theoretically cleanest solution
- ✅ Can use session-scoped fixtures naturally
**Cons**:
- ❌ Requires switching from asyncio to trio backend
- ❌ Major refactoring required
- ❌ May break existing code that assumes asyncio
- ❌ Dependency changes throughout project
- ❌ Team needs to learn trio ecosystem
- ❌ Less ecosystem support than asyncio
**Verdict**: **Not Practical** - Too disruptive for existing projects. Consider only for greenfield projects or major rewrites.
---
## Decision Matrix
| Solution | Code Clarity | Maintenance | Performance | Safety | Effort |
|----------|--------------|-------------|-------------|--------|--------|
| **Solution 1** (Implemented) | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Solution 2 (Task-Isolated) | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
| Solution 3 (Function-Scoped) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Solution 4 (pytest-trio) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
## Implementation Details
### What Changed in Solution 1
1. **`create_mcp_client_session` function** (conftest.py:61-110):
- Replaced manual `__aenter__`/`__aexit__` calls with native `async with` statements
- Removed blanket exception suppression from cleanup logic
- Added clear documentation about LIFO cleanup order
- Simplified from ~60 lines to ~40 lines
2. **Session-scoped MCP client fixtures** (conftest.py:148-1269):
- Added targeted exception handling wrapper
- Only catches specific "cancel scope" + "different task" RuntimeError
- All other exceptions propagate normally
- Applied to: `nc_mcp_client`, `nc_mcp_oauth_client`, `alice_mcp_client`, `bob_mcp_client`, `charlie_mcp_client`, `diana_mcp_client`
3. **Documentation**:
- Added comprehensive docstrings explaining the workaround
- Referenced MCP SDK issue #577 for context
- Documented why this is necessary and not a bug
### Benefits of This Implementation
1. **Clean Core Logic**: The `create_mcp_client_session` function is now clean, idiomatic Python with no workarounds
2. **Isolated Workaround**: Exception handling is confined to pytest fixture level where the issue actually occurs
3. **Surgical Exception Handling**: Only catches the specific expected error, not all RuntimeErrors
4. **Performance**: Maintains session-scoped fixtures for fast test execution
5. **Maintainability**: Easy to understand and modify
6. **Safety**: Real errors still cause test failures
## Testing Results
All tests pass cleanly with the implementation:
```bash
$ uv run pytest tests/server/test_mcp.py -v
============================================= test session starts ==============================================
tests/server/test_mcp.py::test_mcp_connectivity PASSED [ 16%]
tests/server/test_mcp.py::test_mcp_notes_crud_workflow PASSED [ 33%]
tests/server/test_mcp.py::test_mcp_notes_etag_conflict PASSED [ 50%]
tests/server/test_mcp.py::test_mcp_webdav_workflow PASSED [ 66%]
tests/server/test_mcp.py::test_mcp_resources_access PASSED [ 83%]
tests/server/test_mcp.py::test_mcp_calendar_workflow PASSED [100%]
============================================== 6 passed in 39.52s ==============================================
```
## Recommendations
### For This Project: Solution 1 ✅
The implemented solution (Solution 1) is the best fit because:
- Minimal disruption to existing tests
- Clean, maintainable code
- Good performance with session-scoped fixtures
- Targeted exception handling that doesn't hide real errors
### For New Test Files: Consider Solution 3
For new test files where performance isn't critical, consider using function-scoped fixtures (Solution 3):
- No workarounds needed
- Perfect code clarity
- Better test isolation
### For Greenfield Projects: Consider Solution 4
For new projects starting from scratch, consider pytest-trio instead of pytest-asyncio:
- Native structured concurrency support
- No workarounds needed
- Better alignment with modern async Python patterns
## Related Resources
- [MCP Python SDK Issue #577](https://github.com/modelcontextprotocol/python-sdk/issues/577) - Original issue report
- [Anyio Issue #345](https://github.com/agronholm/anyio/issues/345) - Discussion of fixture limitations
- [Nextcloud MCP Note 378555](nextcloud://notes/378555) - Detailed investigation notes
- pytest-asyncio documentation: https://pytest-asyncio.readthedocs.io/
- anyio structured concurrency guide: https://anyio.readthedocs.io/en/stable/basics.html
## Appendix: Why Can't This Be Fixed Upstream?
The incompatibility cannot be "fixed" in either pytest-asyncio or anyio without breaking their core design:
1. **pytest-asyncio** needs to manage fixture lifecycle across different scopes, requiring separate task creation for cleanup
2. **anyio** enforces structured concurrency guarantees by requiring same-task cancel scope entry/exit
3. These requirements are fundamentally incompatible
The maintainers of both projects are aware of this issue, and it's considered an acceptable trade-off given their respective design goals. The recommended approach is to handle it at the application level, as we've done here.
-412
View File
@@ -1,412 +0,0 @@
# Testing OIDC Consent Feature
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
## Setup
### Volume Mount Configuration
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
```yaml
# docker-compose.yml
volumes:
- ../Software/oidc:/opt/apps/oidc:ro
```
**Why mount outside `/var/www/html/`?**
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
- Mounting to `/opt/apps/oidc` avoids rsync entirely
- Nextcloud supports multiple app directories via the `apps_paths` configuration
**How multiple app paths work:**
- Nextcloud can load apps from multiple directories
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
- Apps in default paths (index 0 and 1) are still available
- All directories are scanned for apps, but `/opt/apps` is read-only
This setup allows you to:
- Test changes without rebuilding containers
- Avoid needing npm/node in the container (JS already built on host)
- Iterate quickly on development
- Install other Nextcloud apps normally (custom_apps remains writable)
### How It Works
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
## Starting the Stack
```bash
cd ~/Projects/nextcloud-mcp-server
# Start fresh (recommended for first test)
docker compose down -v
docker compose up -d
# Wait for initialization (check logs)
docker compose logs -f app
```
The post-installation hooks will:
1. Configure custom_apps path (already done)
2. Enable OIDC app from mounted directory
3. Run database migrations (including consent table creation)
4. Configure OIDC settings
## Verifying Installation
### Before Container Restart
Before running `docker compose up -d`, the consent feature will NOT be active:
- ❌ No `oc_oidc_user_consents` table in database
- ❌ Migration 0015 not applied yet
- ❌ ConsentController class not loaded
- ❌ Consent routes not registered
You can verify this with:
```bash
# Check migrations applied (should stop at 0014)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
# Check for consent table (should return empty)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
```
### After Container Restart
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
-`oc_oidc_user_consents` table exists
- ✅ Migration 0015 (Version0015Date20251123100100) applied
- ✅ ConsentController routes registered
- ✅ Consent screen appears during OAuth flows
### Check App Status
```bash
docker compose exec app php occ app:list | grep -A 2 oidc
```
Expected output:
```
- oidc: 1.10.0 (enabled)
```
### Verify App Paths Configuration
Verify that `/opt/apps` is registered as an additional app directory:
```bash
# Check configured app paths
docker compose exec app php occ config:system:get apps_paths
# Verify the mount is accessible
docker compose exec app ls -la /opt/apps/oidc/
# Verify custom_apps is writable (for normal app installation)
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
docker compose exec app rm -f /var/www/html/custom_apps/.test
```
Expected: Output should show multiple app paths including index 2 (/opt/apps).
### Verify Consent Files
```bash
# Check controller exists in mounted location
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
# Check Vue component exists
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
# Check built JS exists
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
### Verify Database Migration
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
```bash
# Check if consent table exists
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
# Check table structure
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
```
Expected table structure:
- id: int(10) unsigned, auto_increment, primary key
- user_id: varchar(256), not null
- client_id: int(10) unsigned, not null
- scopes_granted: varchar(512), not null
- created_at: int(10) unsigned, not null
- updated_at: int(10) unsigned, not null
- expires_at: int(10) unsigned, nullable
### Verify Routes
```bash
docker compose exec app php occ router:list | grep consent
```
Expected output:
```
oidc.Consent.show GET apps/oidc/consent
oidc.Consent.grant POST apps/oidc/consent/grant
oidc.Consent.deny POST apps/oidc/consent/deny
```
## Testing the Consent Flow
### 1. Create an OAuth Client
The JWT client is automatically created by the post-installation hooks:
```bash
# Check if JWT client exists
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
```
### 2. Initiate Authorization Flow
You can test using the MCP OAuth container or manually:
**Option A: Using MCP OAuth container**
```bash
# The mcp-oauth container will trigger the OAuth flow
docker compose logs -f mcp-oauth
```
**Option B: Manual browser test**
1. Get client_id from the JWT client JSON
2. Visit in browser:
```
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
```
### 3. Expected Behavior
**First Authorization:**
1. User logs in (if not already authenticated)
2. **Consent screen appears** with:
- Application name: "Nextcloud MCP Server JWT"
- List of requested scopes with descriptions:
- ✓ Basic authentication (openid) - required, cannot deselect
- ✓ Profile information (profile)
- ✓ Email address (email)
- ✓ mcp:notes:read (custom scope, shown as-is)
- ✓ mcp:notes:write (custom scope, shown as-is)
- "Allow" and "Deny" buttons
3. User selects scopes and clicks "Allow"
4. Authorization proceeds with selected scopes
5. Consent is stored in database
**Subsequent Authorizations:**
- Same scopes → No consent screen (uses stored consent)
- Different scopes → Consent screen appears again
- If user clicks "Deny" → Returns `error=access_denied` to client
### 4. Verify Consent Stored
After granting consent:
```bash
# View all stored consents with formatted timestamps
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
SELECT
user_id,
client_id,
scopes_granted,
FROM_UNIXTIME(created_at) as created,
FROM_UNIXTIME(updated_at) as updated,
FROM_UNIXTIME(expires_at) as expires
FROM oc_oidc_user_consents;
" nextcloud
# Or for a compact view:
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
```
## Troubleshooting
### Consent Screen Not Appearing
**Check browser console** (F12 → Console tab):
```
# Look for JS errors like:
Failed to load resource: js/oidc-consent.js
```
**Check Nextcloud logs:**
```bash
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
```
**Verify JS file loaded:**
```bash
# Check file exists and has correct size (~73KB)
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
**Clear Nextcloud caches:**
```bash
docker compose exec app php occ maintenance:repair
docker compose restart app
```
### Migration Didn't Run
**Check which migrations have been applied:**
```bash
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
```
Expected to see `Version0015Date20251123100100` in the list.
**Manually trigger migrations:**
```bash
# Disable and re-enable app (triggers all pending migrations)
docker compose exec app php occ app:disable oidc
docker compose exec app php occ app:enable oidc
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
```
### Routes Not Registered
If `router:list` doesn't show consent routes:
```bash
# The autoloader might not have picked up new classes
# Restart the container
docker compose restart app
# Wait for it to be ready
sleep 10
# Try again
docker compose exec app php occ router:list | grep consent
```
If still not working, check if ConsentController is accessible:
```bash
docker compose exec app php -r "
require_once '/var/www/html/lib/base.php';
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
if (class_exists(\$class)) {
echo \"Class exists\n\";
} else {
echo \"Class not found\n\";
}
"
```
## Making Changes
### Frontend Changes (Vue.js)
1. Edit source file on host:
```bash
cd ~/Software/oidc
# Edit src/Consent.vue
```
2. Rebuild JS:
```bash
npm run build
```
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
### Backend Changes (PHP)
1. Edit files on host:
```bash
cd ~/Software/oidc
# Edit lib/Controller/ConsentController.php or other PHP files
```
2. Changes are immediately visible (PHP is interpreted, no build step)
3. For new classes or major changes, restart container:
```bash
docker compose restart app
```
### Database Schema Changes
If you modify the migration:
```bash
# Changes won't be picked up if migration already ran
# Need to recreate the database:
docker compose down -v # Removes volumes
docker compose up -d # Fresh start with clean DB
```
## Cleanup
### Reset Everything
```bash
cd ~/Projects/nextcloud-mcp-server
docker compose down -v
```
This removes:
- All containers
- Database volume (all data)
- OAuth client credentials
### Keep Data, Restart App
```bash
docker compose restart app
```
This preserves:
- Database (consents, clients, users)
- OAuth client credentials
## Development Workflow Summary
1. **Make changes** in `~/Software/oidc`
2. **Build JS** if you changed Vue files: `npm run build`
3. **Test immediately** - refresh browser or restart container
4. **No need** to rebuild Docker images or reinstall app
5. **Iterate quickly** with instant feedback
## Production Deployment
When ready to deploy:
1. **Create patch file** (already done):
```bash
cd ~/Software/oidc
git format-patch master --stdout > user-consent-feature.patch
```
2. **Test patch** in clean environment:
```bash
# In a production-like environment
cd /path/to/production/oidc
git apply user-consent-feature.patch
npm install
npm run build
php occ app:disable oidc
php occ app:enable oidc
```
3. **Verify migration** runs automatically on app enable
4. **Submit pull request** to upstream repository
-556
View File
@@ -1,556 +0,0 @@
# Troubleshooting
This guide covers common issues and solutions for the Nextcloud MCP server.
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
## OAuth Issues (Quick Reference)
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
**Cause:** The `NEXTCLOUD_HOST` environment variable is not set or empty.
**Solution:**
```bash
# Ensure NEXTCLOUD_HOST is set in your .env file
echo "NEXTCLOUD_HOST=https://your.nextcloud.instance.com" >> .env
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Verify it's set
echo $NEXTCLOUD_HOST
```
---
### Issue: "OAuth mode requires either client credentials OR dynamic client registration"
**Cause:** The required Nextcloud OIDC apps are either:
1. Not installed (both `oidc` and `user_oidc` apps are required)
2. Don't have dynamic client registration enabled
3. Aren't providing a registration endpoint
**Solution:**
**Option 1: Enable dynamic client registration**
1. Verify **both** OIDC apps are installed:
- Navigate to Nextcloud **Apps****Security**
- Install **"OIDC"** (OIDC Identity Provider app) if not present
- Install **"OpenID Connect user backend"** (user_oidc app) if not present
2. Enable dynamic client registration:
- Go to **Settings****OIDC** (Administration)
- Enable "Allow dynamic client registration"
3. Configure Bearer token validation:
```bash
# Required for user_oidc app to validate tokens
php occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
```
3. Verify the registration endpoint exists:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
# Should output: "https://your.nextcloud.instance.com/apps/oidc/register"
```
**Option 2: Provide pre-configured credentials**
Register a client and add credentials to `.env`:
```bash
# On your Nextcloud server
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
echo "NEXTCLOUD_OIDC_CLIENT_ID=<from-output>" >> .env
echo "NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>" >> .env
```
See [OAuth Setup Guide](oauth-setup.md) for detailed instructions.
---
### Issue: "Stored client has expired"
**Cause:** Dynamically registered OAuth clients expire (default: 1 hour).
**Solution:**
**Option 1: Restart the server** (automatic re-registration)
```bash
# Server checks credentials at startup and re-registers if expired
uv run nextcloud-mcp-server --oauth
```
**Option 2: Use pre-configured credentials** (recommended for production)
```bash
# Register permanent client via Nextcloud CLI
php occ oidc:create \
--name="Nextcloud MCP Server" \
--type=confidential \
--redirect-uri="http://localhost:8000/oauth/callback"
# Add to .env
NEXTCLOUD_OIDC_CLIENT_ID=<from-output>
NEXTCLOUD_OIDC_CLIENT_SECRET=<from-output>
```
**Option 3: Increase expiration time**
```bash
# Via Nextcloud occ command (default: 3600 seconds)
php occ config:app:set oidc expire_time --value "86400" # 24 hours
```
---
### Issue: "HTTP 401 Unauthorized" when calling Nextcloud APIs
**Cause:** OAuth Bearer tokens may not work with certain Nextcloud endpoints due to session handling in the CORS middleware.
**Background:** The `user_oidc` app's CORS middleware interferes with Bearer token authentication for non-OCS endpoints (like Notes API). This affects app-specific APIs but not OCS APIs.
**Solution:**
A patch for the `user_oidc` app is required to fix Bearer token support. See [oauth2-bearer-token-session-issue.md](oauth2-bearer-token-session-issue.md) for:
- Detailed explanation of the issue
- Patch to apply to the `user_oidc` app
- Link to upstream pull request
**Affected endpoints:**
- Notes API (`/apps/notes/api/`)
- Other app-specific endpoints
**Unaffected endpoints:**
- OCS APIs (`/ocs/v2.php/`)
- Capabilities endpoint
---
### Issue: "Permission denied" when reading/writing OAuth client credentials file
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
**Solution:**
```bash
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (should be 0600 - owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure the directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# If the file doesn't exist, ensure the directory is writable so it can be created
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
---
### Issue: "OIDC discovery failed" or "Cannot reach OIDC discovery endpoint"
**Cause:** The server cannot reach the Nextcloud OIDC discovery endpoint.
**Solution:**
1. Verify the Nextcloud URL is correct:
```bash
echo $NEXTCLOUD_HOST
# Should be the full URL: https://your.nextcloud.instance.com
```
2. Test the discovery endpoint manually:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Should return JSON with OIDC configuration
```
3. Check network connectivity:
```bash
ping your.nextcloud.instance.com
```
4. Verify **both** OIDC apps are installed and enabled in Nextcloud:
- `oidc` - OIDC Identity Provider
- `user_oidc` - OpenID Connect user backend
5. Check firewall rules if using Docker
---
### Switching Between OAuth and BasicAuth
#### To switch from BasicAuth to OAuth:
```bash
# 1. Remove or comment out USERNAME/PASSWORD in .env
sed -i 's/^NEXTCLOUD_USERNAME/#NEXTCLOUD_USERNAME/' .env
sed -i 's/^NEXTCLOUD_PASSWORD/#NEXTCLOUD_PASSWORD/' .env
# 2. Ensure NEXTCLOUD_HOST is set
grep NEXTCLOUD_HOST .env
# 3. Restart server with OAuth
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --oauth
```
#### To switch from OAuth to BasicAuth:
```bash
# 1. Add USERNAME/PASSWORD to .env
echo "NEXTCLOUD_USERNAME=your-username" >> .env
echo "NEXTCLOUD_PASSWORD=your-password" >> .env
# 2. Restart server (BasicAuth auto-detected, or use --no-oauth)
export $(grep -v '^#' .env | xargs)
uv run nextcloud-mcp-server --no-oauth
```
---
### For More OAuth Help
See the dedicated **[OAuth Troubleshooting Guide](oauth-troubleshooting.md)** for:
- Bearer token authentication failures
- PKCE validation errors
- Token validation issues
- Client registration problems
- Advanced OAuth debugging
- And much more...
---
## Configuration Issues
### Issue: Environment variables not loaded
**Cause:** Environment variables from `.env` file are not loaded into the shell.
**Solution:**
**On Linux/macOS:**
```bash
# Load all variables from .env
export $(grep -v '^#' .env | xargs)
# Verify variables are set
env | grep NEXTCLOUD
```
**On Windows (PowerShell):**
```powershell
# Load variables from .env
Get-Content .env | ForEach-Object {
if ($_ -match '^\s*([^#][^=]*)\s*=\s*(.*)$') {
[Environment]::SetEnvironmentVariable($matches[1].Trim(), $matches[2].Trim(), "Process")
}
}
# Verify variables are set
Get-ChildItem Env:NEXTCLOUD*
```
**With Docker:**
```bash
# Docker automatically loads .env when using --env-file
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
---
### Issue: ".env file not found"
**Cause:** The `.env` file doesn't exist or is in the wrong location.
**Solution:**
```bash
# Create .env from sample
cp env.sample .env
# Edit with your Nextcloud details
nano .env # or vim, code, etc.
# Ensure you're in the correct directory when running commands
pwd # Should be in the project directory containing .env
```
---
### Issue: "Invalid Nextcloud credentials"
**Cause:** BasicAuth credentials are incorrect or the app password has been revoked.
**Solution:**
1. **Verify username:**
```bash
# Username should match your Nextcloud login
echo $NEXTCLOUD_USERNAME
```
2. **Generate a new app password:**
- Log in to Nextcloud
- Go to **Settings** → **Security**
- Under "Devices & sessions", create a new app password
- Update `.env` with the new password
3. **Test credentials manually:**
```bash
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
-H "OCS-APIRequest: true"
# Should return XML with capabilities
```
---
## Server Issues
### Issue: "Address already in use" / Port conflict
**Cause:** Another process is using port 8000.
**Solution:**
**Option 1: Use a different port**
```bash
uv run nextcloud-mcp-server --port 8080
```
**Option 2: Find and kill the process using the port**
```bash
# On Linux/macOS
lsof -ti:8000 | xargs kill -9
# On Windows
netstat -ano | findstr :8000
taskkill /PID <pid> /F
```
**Option 3: Stop other MCP server instances**
```bash
# Check for running instances
ps aux | grep nextcloud-mcp-server
# Kill specific process
kill <pid>
```
---
### Issue: Server starts but can't connect
**Cause:** Server is bound to localhost only, or firewall is blocking connections.
**Solution:**
1. **Check server binding:**
```bash
# Bind to all interfaces to allow network access
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
```
2. **Test connectivity:**
```bash
# Test from same machine
curl http://localhost:8000/health
# Test from network (if using --host 0.0.0.0)
curl http://<server-ip>:8000/health
```
3. **Check firewall:**
```bash
# Linux (ufw)
sudo ufw allow 8000/tcp
# Linux (firewalld)
sudo firewall-cmd --add-port=8000/tcp --permanent
sudo firewall-cmd --reload
```
---
### Issue: Server crashes or restarts frequently
**Cause:** Various issues including memory limits, uncaught exceptions, or OAuth token expiration.
**Solution:**
1. **Check logs with debug level:**
```bash
uv run nextcloud-mcp-server --log-level debug
```
2. **Monitor resource usage:**
```bash
# Check memory and CPU
top -p $(pgrep -f nextcloud-mcp-server)
```
3. **Use process manager for automatic restart:**
```bash
# With systemd (see Running guide for full config)
sudo systemctl restart nextcloud-mcp
# With Docker Compose (includes restart: unless-stopped)
docker-compose up -d
```
4. **Check for OAuth credential expiration** (if using dynamic registration):
- See ["Stored client has expired"](#issue-stored-client-has-expired) above
---
## Connection Issues
### Issue: MCP client can't authenticate
**Cause:** OAuth flow failing or credentials invalid.
**Solution:**
**For OAuth:**
1. Verify OAuth is configured correctly:
```bash
uv run nextcloud-mcp-server --oauth --log-level debug
# Look for "OAuth initialization complete"
```
2. Check that OIDC app is accessible:
```bash
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
```
3. Verify MCP_SERVER_URL matches your setup:
```bash
echo $NEXTCLOUD_MCP_SERVER_URL
# Should match the URL clients use to connect
```
**For BasicAuth:**
1. Verify credentials work:
```bash
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities" \
-H "OCS-APIRequest: true"
```
---
### Issue: Tools return errors or don't work
**Cause:** Missing Nextcloud apps, incorrect permissions, or API issues.
**Solution:**
1. **Verify required Nextcloud apps are installed:**
- Notes: Install "Notes" app
- Calendar: Ensure CalDAV is enabled
- Contacts: Ensure CardDAV is enabled
- Deck: Install "Deck" app
2. **Check user permissions:**
- Ensure the authenticated user has access to the resources
- Check sharing permissions for shared resources
3. **Test API directly:**
```bash
# Test Notes API
curl -u "$NEXTCLOUD_USERNAME:$NEXTCLOUD_PASSWORD" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
# Test with OAuth Bearer token
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
4. **Check server logs for specific errors:**
```bash
uv run nextcloud-mcp-server --log-level debug
```
---
## Getting Help
If you continue to experience issues:
### 1. Enable Debug Logging
```bash
uv run nextcloud-mcp-server --log-level debug
```
Review the logs for specific error messages.
### 2. Verify OIDC Configuration (OAuth mode)
```bash
# Check OIDC discovery
curl https://your.nextcloud.instance.com/.well-known/openid-configuration
# Check registration endpoint exists
curl https://your.nextcloud.instance.com/.well-known/openid-configuration | jq '.registration_endpoint'
```
### 3. Test Nextcloud API Access
```bash
# Test OCS API (should work with OAuth)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/ocs/v2.php/cloud/capabilities?format=json" \
-H "OCS-APIRequest: true"
# Test app API (may need patch - see oauth2-bearer-token-session-issue.md)
curl -H "Authorization: Bearer $TOKEN" \
"$NEXTCLOUD_HOST/apps/notes/api/v1/notes"
```
### 4. Check Versions
```bash
# MCP Server version
uv run nextcloud-mcp-server --version
# Python version
python3 --version
# Nextcloud version (check in admin panel)
```
### 5. Open an Issue
If problems persist, open an issue on the [GitHub repository](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) with:
- **Server logs** (with `--log-level debug`)
- **Nextcloud version**
- **OIDC app version** (if using OAuth)
- **Error messages**
- **Steps to reproduce**
- **Environment details** (OS, Python version, Docker vs local)
---
## See Also
- **[OAuth Troubleshooting](oauth-troubleshooting.md)** - Dedicated OAuth troubleshooting guide
- [OAuth Setup Guide](oauth-setup.md) - OAuth configuration
- [OAuth Architecture](oauth-architecture.md) - How OAuth works
- [Upstream Status](oauth-upstream-status.md) - Required patches and upstream PRs
- [Configuration](configuration.md) - Environment variables
- [Running the Server](running.md) - Server options
-20
View File
@@ -1,23 +1,3 @@
# Nextcloud Instance
NEXTCLOUD_HOST=
# ===== AUTHENTICATION MODE =====
# Choose ONE of the following:
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
# - Use only for backward compatibility or if OAuth unavailable
# - If these are set, OAuth mode is disabled
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
+54 -788
View File
@@ -1,458 +1,67 @@
import click
import logging
import os
import uvicorn
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
import click
import httpx
import uvicorn
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from starlette.responses import JSONResponse
from nextcloud_mcp_server.auth import (
InsufficientScopeError,
NextcloudTokenVerifier,
get_access_token_scopes,
has_required_scopes,
is_jwt_token,
)
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.config import setup_logging
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.server import (
configure_calendar_tools,
configure_contacts_tools,
configure_cookbook_tools,
configure_deck_tools,
configure_notes_tools,
configure_sharing_tools,
configure_tables_tools,
configure_webdav_tools,
configure_deck_tools,
)
logger = logging.getLogger(__name__)
def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
"""
Validate that the OIDC provider properly advertises PKCE support.
According to RFC 8414, if code_challenge_methods_supported is absent,
it means the authorization server does not support PKCE.
MCP clients require PKCE with S256 and will refuse to connect if this
field is missing or doesn't include S256.
"""
code_challenge_methods = discovery.get("code_challenge_methods_supported")
if code_challenge_methods is None:
click.echo("=" * 80, err=True)
click.echo(
"ERROR: OIDC CONFIGURATION ERROR - Missing PKCE Support Advertisement",
err=True,
)
click.echo("=" * 80, err=True)
click.echo(f"Discovery URL: {discovery_url}", err=True)
click.echo("", err=True)
click.echo(
"The OIDC discovery document is missing 'code_challenge_methods_supported'.",
err=True,
)
click.echo(
"According to RFC 8414, this means the server does NOT support PKCE.",
err=True,
)
click.echo("", err=True)
click.echo("⚠️ MCP clients (like Claude Code) WILL REJECT this provider!")
click.echo("", err=True)
click.echo("How to fix:", err=True)
click.echo(
" 1. Ensure PKCE is enabled in Nextcloud OIDC app settings", err=True
)
click.echo(
" 2. Update the OIDC app to advertise PKCE support in discovery", err=True
)
click.echo(" 3. See: RFC 8414 Section 2 (Authorization Server Metadata)")
click.echo("=" * 80, err=True)
click.echo("", err=True)
return
if "S256" not in code_challenge_methods:
click.echo("=" * 80, err=True)
click.echo(
"WARNING: OIDC CONFIGURATION WARNING - S256 Challenge Method Not Advertised",
err=True,
)
click.echo("=" * 80, err=True)
click.echo(f"Discovery URL: {discovery_url}", err=True)
click.echo(f"Advertised methods: {code_challenge_methods}", err=True)
click.echo("", err=True)
click.echo("MCP specification requires S256 code challenge method.", err=True)
click.echo("Some clients may reject this provider.", err=True)
click.echo("=" * 80, err=True)
click.echo("", err=True)
return
click.echo(f"✓ PKCE support validated: {code_challenge_methods}")
@dataclass
class AppContext:
"""Application context for BasicAuth mode."""
client: NextcloudClient
@dataclass
class OAuthAppContext:
"""Application context for OAuth mode."""
nextcloud_host: str
token_verifier: NextcloudTokenVerifier
def is_oauth_mode() -> bool:
"""
Determine if OAuth mode should be used.
OAuth mode is enabled when:
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
- Or explicitly enabled via configuration
Returns:
True if OAuth mode, False if BasicAuth mode
"""
username = os.getenv("NEXTCLOUD_USERNAME")
password = os.getenv("NEXTCLOUD_PASSWORD")
# If both username and password are set, use BasicAuth
if username and password:
logger.info(
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
)
return False
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
return True
async def load_oauth_client_credentials(
nextcloud_host: str, registration_endpoint: str | None
) -> tuple[str, str]:
"""
Load OAuth client credentials from environment, storage file, or dynamic registration.
This consolidates the client loading logic that was duplicated across multiple functions.
Args:
nextcloud_host: Nextcloud instance URL
registration_endpoint: Dynamic registration endpoint URL (or None if not available)
Returns:
Tuple of (client_id, client_secret)
Raises:
ValueError: If credentials cannot be obtained
"""
# Try environment variables first
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials from environment")
return (client_id, client_secret)
# Try loading from storage file
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
from pathlib import Path
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
client_info = load_client_from_file(Path(storage_path))
if client_info:
logger.info(
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
)
return (client_info.client_id, client_info.client_secret)
# Try dynamic registration if available
if registration_endpoint:
logger.info("Dynamic client registration available")
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Get scopes from environment or use defaults
# Default: all app-specific read/write scopes
default_scopes = (
"openid profile email "
"notes:read notes:write "
"calendar:read calendar:write "
"todo:read todo:write "
"contacts:read contacts:write "
"cookbook:read cookbook:write "
"deck:read deck:write "
"tables:read tables:write "
"files:read files:write "
"sharing:read sharing:write"
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
# Note: Must be lowercase "jwt" to match OIDC app's check
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
# Special case: "bearer" should remain capitalized for compatibility
if token_type != "jwt":
token_type = "Bearer"
logger.info(f"Requesting token type: {token_type}")
# Load or register client
from nextcloud_mcp_server.auth.client_registration import (
load_or_register_client,
)
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name=f"Nextcloud MCP Server ({token_type})",
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
return (client_info.client_id, client_info.client_secret)
# No credentials available
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app"
)
@asynccontextmanager
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
Manage application lifecycle for BasicAuth mode.
Creates a single Nextcloud client with basic authentication
that is shared across all requests.
"""
logger.info("Starting MCP server in BasicAuth mode")
logger.info("Creating Nextcloud client with BasicAuth")
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
"""Manage application lifecycle with type-safe context"""
# Initialize on startup
logging.info("Creating Nextcloud client")
client = NextcloudClient.from_env()
logger.info("Client initialization complete")
logging.info("Client initialization wait complete.")
try:
yield AppContext(client=client)
finally:
logger.info("Shutting down BasicAuth mode")
# Cleanup on shutdown
await client.close()
@asynccontextmanager
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Manage application lifecycle for OAuth mode.
Initializes OAuth client registration and token verifier.
Does NOT create a Nextcloud client - clients are created per-request.
"""
logger.info("Starting MCP server in OAuth mode")
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST environment variable is required")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OAuth discovery endpoint
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
try:
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info(f"OIDC discovery successful: {discovery_url}")
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
introspection_uri = discovery.get("introspection_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
if introspection_uri:
logger.info(f"Introspection endpoint: {introspection_uri}")
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with introspection support
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
introspection_uri=introspection_uri,
client_id=client_id,
client_secret=client_secret,
)
logger.info("OAuth initialization complete")
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host, token_verifier=token_verifier
)
finally:
logger.info("Shutting down OAuth mode")
await token_verifier.close()
except Exception as e:
logger.error(f"Failed to initialize OAuth mode: {e}")
raise
async def heartbeat(request):
return JSONResponse({"status": "ok"})
async def setup_oauth_config():
"""
Setup OAuth configuration by performing OIDC discovery and client registration.
This is done synchronously before FastMCP initialization because FastMCP
requires token_verifier at construction time.
Returns:
Tuple of (nextcloud_host, token_verifier, auth_settings)
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError(
"NEXTCLOUD_HOST environment variable is required for OAuth mode"
)
nextcloud_host = nextcloud_host.rstrip("/")
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC discovery successful")
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
jwt_validation_issuer = public_issuer
else:
# Use discovered issuer for both
jwt_validation_issuer = issuer
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: We don't set required_scopes here anymore.
# Scopes are now advertised via PRM endpoint and enforced per-tool.
# This allows dynamic tool filtering based on user's actual token scopes.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(mcp_server_url),
)
logger.info("OAuth configuration complete")
return nextcloud_host, token_verifier, auth_settings
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
setup_logging()
# Determine authentication mode
oauth_enabled = is_oauth_mode()
if oauth_enabled:
logger.info("Configuring MCP server for OAuth mode")
# Asynchronously get the OAuth configuration
import anyio
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
token_verifier=token_verifier,
auth=auth_settings,
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
# Create an MCP server
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
"""Get the Nextcloud Host capabilities"""
ctx: Context = mcp.get_context()
client = get_nextcloud_client(ctx)
ctx: Context = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.capabilities()
# Define available apps and their configuration functions
@@ -460,10 +69,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
"notes": configure_notes_tools,
"tables": configure_tables_tools,
"webdav": configure_webdav_tools,
"sharing": configure_sharing_tools,
"calendar": configure_calendar_tools,
"contacts": configure_contacts_tools,
"cookbook": configure_cookbook_tools,
"deck": configure_deck_tools,
}
@@ -481,59 +88,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
# Override list_tools to filter based on user's token scopes (OAuth mode only)
if oauth_enabled:
original_list_tools = mcp._tool_manager.list_tools
def list_tools_filtered():
"""List tools filtered by user's token scopes (JWT and Bearer tokens)."""
# Get user's scopes from token using MCP SDK's contextvar
# This works for all request types including list_tools
user_scopes = get_access_token_scopes()
is_jwt = is_jwt_token()
logger.info(
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
f"User scopes: {user_scopes}"
)
# Get all tools
all_tools = original_list_tools()
# Filter tools based on user's token scopes (both JWT and opaque tokens)
# JWT tokens have scopes embedded in payload
# Opaque tokens get scopes via introspection endpoint
# Claude Code now properly respects PRM endpoint for scope discovery
if user_scopes:
allowed_tools = [
tool
for tool in all_tools
if has_required_scopes(tool.fn, user_scopes)
]
token_type = "JWT" if is_jwt else "Bearer"
logger.info(
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
f"available for scopes: {user_scopes}"
)
else:
# BasicAuth mode or no token - show all tools
allowed_tools = all_tools
logger.info(
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
)
# Return the Tool objects directly (they're already in the correct format)
return allowed_tools
# Replace the tool manager's list_tools method
mcp._tool_manager.list_tools = list_tools_filtered
logger.info(
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
)
if transport == "sse":
mcp.settings.sse_path = "/"
mcp_app = mcp.sse_app()
lifespan = None
elif transport in ("http", "streamable-http"):
else:
mcp.settings.streamable_http_path = "/"
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
@@ -542,363 +102,69 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
await stack.enter_async_context(mcp.session_manager.run())
yield
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
routes = []
if oauth_enabled:
def oauth_protected_resource_metadata(request):
"""RFC 9728 Protected Resource Metadata endpoint."""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
resource_url = f"{mcp_server_url}/mcp"
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
return JSONResponse(
{
"resource": resource_url,
"scopes_supported": [
"openid",
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"cookbook:read",
"cookbook:write",
"deck:read",
"deck:write",
"tables:read",
"tables:write",
"files:read",
"files:write",
"sharing:read",
"sharing:write",
],
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
)
# Register PRM endpoint at both path-based and root locations per RFC 9728
# Path-based discovery: /.well-known/oauth-protected-resource{path}
routes.append(
Route(
"/.well-known/oauth-protected-resource/mcp",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
# Root discovery (fallback): /.well-known/oauth-protected-resource
routes.append(
Route(
"/.well-known/oauth-protected-resource",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
logger.info(
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan)
# Add CORS middleware to allow browser-based clients like MCP Inspector
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
app = Starlette(
routes=[
Route("/heartbeat", endpoint=heartbeat),
Mount("/sse" if transport == "sse" else "/mcp", app=mcp_app),
],
lifespan=lifespan,
)
# Add exception handler for scope challenges (OAuth mode only)
if oauth_enabled:
@app.exception_handler(InsufficientScopeError)
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
"""Return 403 with WWW-Authenticate header for scope challenges."""
resource_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
scope_str = " ".join(exc.missing_scopes)
return JSONResponse(
status_code=403,
headers={
"WWW-Authenticate": (
f'Bearer error="insufficient_scope", '
f'scope="{scope_str}", '
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
)
},
content={
"error": "insufficient_scope",
"scopes_required": exc.missing_scopes,
},
)
logger.info("WWW-Authenticate scope challenge handler enabled")
return app
@click.command()
@click.option(
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
)
@click.option(
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
)
@click.option("--host", "-h", default="127.0.0.1", show_default=True)
@click.option("--port", "-p", type=int, default=8000, show_default=True)
@click.option("--workers", "-w", type=int, default=None)
@click.option("--reload", "-r", is_flag=True)
@click.option(
"--log-level",
"-l",
default="info",
show_default=True,
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
help="Logging level",
)
@click.option(
"--transport",
"-t",
default="sse",
show_default=True,
type=click.Choice(["sse", "streamable-http", "http"]),
help="MCP transport protocol",
type=click.Choice(["sse", "streamable-http"]),
)
@click.option(
"--enable-app",
"-e",
multiple=True,
type=click.Choice(
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
),
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
)
@click.option(
"--oauth/--no-oauth",
default=None,
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
)
@click.option(
"--oauth-client-id",
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
)
@click.option(
"--oauth-client-secret",
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
)
@click.option(
"--oauth-storage-path",
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
default=".nextcloud_oauth_client.json",
show_default=True,
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
)
@click.option(
"--mcp-server-url",
envvar="NEXTCLOUD_MCP_SERVER_URL",
default="http://localhost:8000",
show_default=True,
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
)
@click.option(
"--nextcloud-host",
envvar="NEXTCLOUD_HOST",
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
)
@click.option(
"--nextcloud-username",
envvar="NEXTCLOUD_USERNAME",
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
)
@click.option(
"--nextcloud-password",
envvar="NEXTCLOUD_PASSWORD",
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
)
@click.option(
"--oauth-scopes",
envvar="NEXTCLOUD_OIDC_SCOPES",
default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@click.option(
"--oauth-token-type",
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
default="bearer",
show_default=True,
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
)
@click.option(
"--public-issuer-url",
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
)
def run(
host: str,
port: int,
workers: int,
reload: bool,
log_level: str,
transport: str,
enable_app: tuple[str, ...],
oauth: bool | None,
oauth_client_id: str | None,
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
nextcloud_host: str | None,
nextcloud_username: str | None,
nextcloud_password: str | None,
oauth_scopes: str,
oauth_token_type: str,
public_issuer_url: str | None,
):
"""
Run the Nextcloud MCP server.
\b
Authentication Modes:
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
\b
Examples:
# BasicAuth mode with CLI options
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
--nextcloud-username=admin --nextcloud-password=secret
# BasicAuth mode with env vars (recommended for credentials)
$ export NEXTCLOUD_HOST=https://cloud.example.com
$ export NEXTCLOUD_USERNAME=admin
$ export NEXTCLOUD_PASSWORD=secret
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
# OAuth mode with auto-registration
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
# OAuth mode with pre-configured client
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-client-id=xxx --oauth-client-secret=yyy
# OAuth mode with custom scopes and JWT tokens
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
# OAuth with public issuer URL (for Docker/proxy setups)
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
--public-issuer-url=http://localhost:8080
"""
# Set env vars from CLI options if provided
if nextcloud_host:
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
if nextcloud_username:
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
if nextcloud_password:
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
if oauth_client_id:
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
if oauth_client_secret:
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
if oauth_storage_path:
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
if oauth_scopes:
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
if oauth_token_type:
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
if mcp_server_url:
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
if public_issuer_url:
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
# Force OAuth mode if explicitly requested
if oauth is True:
# Clear username/password to force OAuth mode
if "NEXTCLOUD_USERNAME" in os.environ:
click.echo(
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
)
del os.environ["NEXTCLOUD_USERNAME"]
if "NEXTCLOUD_PASSWORD" in os.environ:
click.echo(
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
)
del os.environ["NEXTCLOUD_PASSWORD"]
# Validate OAuth configuration
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise click.ClickException(
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
)
# Check if we have client credentials OR if dynamic registration is possible
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
"NEXTCLOUD_OIDC_CLIENT_SECRET"
)
if not has_client_creds:
# No client credentials - will attempt dynamic registration
# Show helpful message before server starts
click.echo("", err=True)
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Dynamic Client Registration", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(
" Storage: "
+ os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
),
err=True,
)
click.echo("", err=True)
click.echo(
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
)
click.echo(" in your Nextcloud OIDC app settings.", err=True)
click.echo("", err=True)
else:
click.echo("", err=True)
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Pre-configured Client", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(
" Client ID: "
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
+ "...",
err=True,
)
click.echo("", err=True)
elif oauth is False:
# Force BasicAuth mode - verify credentials exist
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
raise click.ClickException(
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
)
enabled_apps = list(enable_app) if enable_app else None
app = get_app(transport=transport, enabled_apps=enabled_apps)
if reload or workers:
app = "nextcloud_mcp_server.app:get_app"
factory = True
else:
app = get_app(transport=transport, enabled_apps=enabled_apps)
factory = False
uvicorn.run(
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
app=app,
factory=factory,
host=host,
port=port,
reload=reload,
workers=workers,
log_level=log_level,
)
-32
View File
@@ -1,32 +0,0 @@
"""OAuth authentication components for Nextcloud MCP server."""
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
ScopeAuthorizationError,
check_scopes,
get_access_token_scopes,
get_required_scopes,
has_required_scopes,
is_jwt_token,
require_scopes,
)
from .token_verifier import NextcloudTokenVerifier
__all__ = [
"BearerAuth",
"NextcloudTokenVerifier",
"register_client",
"load_or_register_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
"InsufficientScopeError",
"check_scopes",
"get_access_token_scopes",
"get_required_scopes",
"has_required_scopes",
"is_jwt_token",
]
-34
View File
@@ -1,34 +0,0 @@
"""Bearer token authentication for httpx."""
from httpx import Auth, Request
class BearerAuth(Auth):
"""
Bearer token authentication flow for httpx.
This auth class adds the Authorization: Bearer <token> header
to all outgoing requests.
"""
def __init__(self, token: str):
"""
Initialize bearer authentication.
Args:
token: The bearer token to use for authentication
"""
self.token = token
def auth_flow(self, request: Request):
"""
Add Authorization header to the request.
Args:
request: The outgoing HTTP request
Yields:
The modified request with Authorization header
"""
request.headers["Authorization"] = f"Bearer {self.token}"
yield request
@@ -1,420 +0,0 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
import anyio
import httpx
logger = logging.getLogger(__name__)
class ClientInfo:
"""Client registration information with RFC 7592 support."""
def __init__(
self,
client_id: str,
client_secret: str,
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: str | None = None,
registration_client_uri: str | None = None,
):
self.client_id = client_id
self.client_secret = client_secret
self.client_id_issued_at = client_id_issued_at
self.client_secret_expires_at = client_secret_expires_at
self.redirect_uris = redirect_uris
self.registration_access_token = registration_access_token
self.registration_client_uri = registration_client_uri
@property
def is_expired(self) -> bool:
"""Check if the client has expired."""
return time.time() >= self.client_secret_expires_at
@property
def expires_soon(self) -> bool:
"""Check if client expires within 5 minutes."""
return time.time() >= (self.client_secret_expires_at - 300)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for storage."""
result = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"client_id_issued_at": self.client_id_issued_at,
"client_secret_expires_at": self.client_secret_expires_at,
"redirect_uris": self.redirect_uris,
}
if self.registration_access_token:
result["registration_access_token"] = self.registration_access_token
if self.registration_client_uri:
result["registration_client_uri"] = self.registration_client_uri
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
"""Create from dictionary."""
return cls(
client_id=data["client_id"],
client_secret=data["client_secret"],
client_id_issued_at=data["client_id_issued_at"],
client_secret_expires_at=data["client_secret_expires_at"],
redirect_uris=data["redirect_uris"],
registration_access_token=data.get("registration_access_token"),
registration_client_uri=data.get("registration_client_uri"),
)
async def register_client(
nextcloud_url: str,
registration_endpoint: str,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
client_name: Name of the client application
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
scopes: Space-separated list of scopes to request
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with registration details
Raises:
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
if redirect_uris is None:
redirect_uris = ["http://localhost:8000/oauth/callback"]
client_metadata = {
"client_name": client_name,
"redirect_uris": redirect_uris,
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": scopes,
"token_type": token_type,
}
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
"""
Load client credentials from storage file.
Args:
storage_path: Path to the JSON file containing client credentials
Returns:
ClientInfo if file exists and is valid, None otherwise
"""
if not storage_path.exists():
logger.debug(f"Client storage file not found: {storage_path}")
return None
try:
with open(storage_path, "r") as f:
data = json.load(f)
client_info = ClientInfo.from_dict(data)
if client_info.is_expired:
logger.warning(
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
)
return None
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
if client_info.expires_soon:
logger.warning("Client expires soon (within 5 minutes)")
return client_info
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error(f"Failed to load client from file: {e}")
return None
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
"""
Save client credentials to storage file.
Args:
client_info: Client information to save
storage_path: Path to save the JSON file
Raises:
OSError: If file cannot be written
"""
try:
# Create directory if it doesn't exist
storage_path.parent.mkdir(parents=True, exist_ok=True)
# Write client info
with open(storage_path, "w") as f:
json.dump(client_info.to_dict(), f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(storage_path, 0o600)
logger.info(f"Saved client credentials to {storage_path}")
except OSError as e:
logger.error(f"Failed to save client credentials: {e}")
raise
async def delete_client(
nextcloud_url: str,
client_id: str,
registration_access_token: str | None = None,
client_secret: str | None = None,
registration_client_uri: str | None = None,
max_retries: int = 3,
) -> bool:
"""
Delete a dynamically registered OAuth client using RFC 7592.
This implements RFC 7592 Section 2.3 (Client Delete Request).
Prefers Bearer token authentication (RFC 7592 standard) but falls back
to HTTP Basic Auth if registration_access_token is not available.
Args:
nextcloud_url: Base URL of the Nextcloud instance
client_id: Client identifier to delete
registration_access_token: RFC 7592 registration access token (preferred)
client_secret: Client secret for fallback HTTP Basic Auth
registration_client_uri: RFC 7592 client configuration URI (optional)
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
True if deletion successful, False otherwise
Note:
RFC 7592 deletion endpoint: {registration_client_uri} or {nextcloud_url}/apps/oidc/register/{client_id}
Authentication methods (in order of preference):
1. Bearer token: Authorization: Bearer {registration_access_token} (RFC 7592 standard)
2. HTTP Basic Auth: client_id as username, client_secret as password (fallback)
"""
# Determine deletion endpoint
if registration_client_uri:
deletion_endpoint = registration_client_uri
else:
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
for attempt in range(max_retries):
try:
# Prefer RFC 7592 Bearer token authentication
if registration_access_token:
logger.debug("Using RFC 7592 Bearer token authentication")
response = await http_client.delete(
deletion_endpoint,
headers={
"Authorization": f"Bearer {registration_access_token}"
},
)
elif client_secret:
logger.debug(
"Falling back to HTTP Basic Auth (registration_access_token not available)"
)
response = await http_client.delete(
deletion_endpoint,
auth=(client_id, client_secret),
)
else:
logger.error(
"Cannot delete client: no registration_access_token or client_secret provided"
)
return False
# RFC 7592: Successful deletion returns 204 No Content
if response.status_code == 204:
logger.info(
f"Successfully deleted OAuth client: {client_id[:16]}..."
)
return True
elif response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(
retry_after, 2**attempt
) # Exponential backoff, max from header
logger.warning(
f"Rate limited (429) deleting client {client_id[:16]}..., "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
)
return False
elif response.status_code == 401:
logger.error(
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
)
return False
elif response.status_code == 403:
logger.error(
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
)
return False
else:
logger.error(
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
)
logger.debug(f"Response: {response.text}")
return False
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
)
logger.debug(f"Response: {e.response.text}")
return False
except Exception as e:
logger.error(
f"Unexpected error deleting client {client_id[:16]}...: {e}"
)
return False
# Should not reach here, but return False if we do
return False
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
This function:
1. Checks for existing client credentials in storage
2. Validates the credentials are not expired
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
storage_path: Path to store client credentials
client_name: Name of the client application
redirect_uris: List of redirect URIs
scopes: Space-separated list of scopes to request (default: "openid profile email")
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with valid credentials
Raises:
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
storage_path = Path(storage_path)
# Try to load existing client
client_info = load_client_from_file(storage_path)
if client_info:
return client_info
# Register new client
logger.info("Registering new OAuth client...")
client_info = await register_client(
nextcloud_url=nextcloud_url,
registration_endpoint=registration_endpoint,
client_name=client_name,
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
# Save to storage
save_client_to_file(client_info, storage_path)
return client_info
@@ -1,65 +0,0 @@
"""Helper functions for extracting OAuth context from MCP requests."""
import logging
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from ..client import NextcloudClient
logger = logging.getLogger(__name__)
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
Extract authenticated user context from MCP request and create NextcloudClient.
This function retrieves the OAuth access token from the MCP context,
extracts the username from the token's resource field (where we stored it
during token verification), and creates a NextcloudClient with bearer auth.
Args:
ctx: MCP request context containing session info
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with bearer token auth
Raises:
AttributeError: If context doesn't contain expected OAuth session data
ValueError: If username cannot be extracted from token
"""
try:
# In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user
# The FastMCP auth middleware sets request.user to an AuthenticatedUser object
# which contains the access_token
if hasattr(ctx.request_context.request, "user") and hasattr(
ctx.request_context.request.user, "access_token"
):
access_token: AccessToken = ctx.request_context.request.user.access_token
logger.debug("Retrieved access token from request.user for OAuth request")
else:
logger.error(
"OAuth authentication failed: No access token found in request"
)
raise AttributeError("No access token found in OAuth request context")
# Extract username from resource field (RFC 8707)
# We stored the username here during token verification
username = access_token.resource
if not username:
logger.error("No username found in access token resource field")
raise ValueError("Username not available in OAuth token context")
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
# Create client with bearer token
return NextcloudClient.from_token(
base_url=base_url, token=access_token.token, username=username
)
except AttributeError as e:
logger.error(f"Failed to extract OAuth context: {e}")
logger.error("This may indicate the server is not running in OAuth mode")
raise
@@ -1,278 +0,0 @@
"""Scope-based authorization for MCP tools."""
import logging
from functools import wraps
from typing import Callable
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
logger = logging.getLogger(__name__)
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
pass
class InsufficientScopeError(ScopeAuthorizationError):
"""Raised when request lacks required scopes (enables step-up auth).
This exception triggers a 403 response with WWW-Authenticate header
containing the missing scopes, allowing clients to perform step-up
authorization to obtain additional permissions.
"""
def __init__(self, missing_scopes: list[str], message: str | None = None):
self.missing_scopes = missing_scopes
super().__init__(
message or f"Missing required scopes: {', '.join(missing_scopes)}"
)
def require_scopes(*required_scopes: str):
"""
Decorator to require specific OAuth scopes for MCP tool execution.
This decorator:
1. Stores scope requirements as function metadata (_required_scopes attribute)
2. Checks that the access token contains all required scopes before execution
3. Raises ScopeAuthorizationError if any required scope is missing
The stored metadata enables dynamic tool filtering - tools can be hidden from
users who lack the necessary scopes.
Args:
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
Returns:
Decorated function that checks scopes before execution
Example:
```python
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the notes:read scope
...
@mcp.tool()
@require_scopes("notes:write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the notes:write scope
...
```
Raises:
ScopeAuthorizationError: If required scopes are not present in the access token
"""
def decorator(func: Callable):
# Store scope requirements as function metadata for dynamic filtering
func._required_scopes = list(required_scopes) # type: ignore
# Find which parameter receives the Context (FastMCP injects it by name)
context_param_name = find_context_parameter(func)
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract context from kwargs (where FastMCP injected it)
ctx: Context | None = (
kwargs.get(context_param_name) if context_param_name else None
)
if ctx is None:
# No context parameter found - likely BasicAuth mode
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Check if we're in OAuth mode (access token available)
access_token: AccessToken | None = getattr(
ctx.request_context, "access_token", None
)
if access_token is None:
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
# Check if all required scopes are present
missing_scopes = required_scopes_set - token_scopes
if missing_scopes:
error_msg = (
f"Access denied to {func.__name__}: "
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing_scopes), error_msg)
# All required scopes present - allow execution
logger.debug(
f"Scope authorization passed for {func.__name__}: {required_scopes}"
)
return await func(*args, **kwargs)
return wrapper
return decorator
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
"""
Extract scopes from the authenticated user's access token.
This function uses MCP SDK's contextvar to access the token, which works
across all request types including list_tools.
Args:
ctx: FastMCP context object (unused, kept for compatibility)
Returns:
Set of scope strings, empty set if no token or no scopes
"""
# Use MCP SDK's get_access_token() which uses contextvars
# This works for all request types, including list_tools
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found in auth context (likely BasicAuth mode)")
return set()
scopes = set(access_token.scopes or [])
logger.info(f"✅ Extracted scopes from access token: {scopes}")
return scopes
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
"""
Check if the request context has all required scopes.
Utility function for manual scope checking without decorator.
Args:
ctx: FastMCP context object
*required_scopes: Variable number of required scope strings
Returns:
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
Example:
```python
async def my_tool(ctx: Context):
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
if not has_scopes:
# Handle missing scopes
...
```
"""
token_scopes = get_access_token_scopes(ctx)
# If no access token, assume BasicAuth mode (all operations allowed)
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
return True, set()
required_scopes_set = set(required_scopes)
missing_scopes = required_scopes_set - token_scopes
return len(missing_scopes) == 0, missing_scopes
def get_required_scopes(func: Callable) -> list[str]:
"""
Extract required scopes from a function decorated with @require_scopes.
Args:
func: Function to check (may be decorated)
Returns:
List of required scope strings, empty list if no scopes required
Example:
```python
@require_scopes("notes:read", "notes:write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
```
"""
return getattr(func, "_required_scopes", [])
def is_jwt_token() -> bool:
"""
Check if the current access token is in JWT format.
JWT tokens have 3 parts separated by dots (header.payload.signature).
Opaque tokens are random strings without this structure.
Returns:
True if current token is JWT format, False if opaque or no token
"""
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found - not JWT")
return False
# JWT tokens have exactly 2 dots (3 parts)
token_string = access_token.token
is_jwt = "." in token_string and token_string.count(".") == 2
logger.debug(f"Token format check: is_jwt={is_jwt}")
return is_jwt
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
"""
Check if a user has all scopes required by a function.
Used for dynamic tool filtering - determines if a tool should be visible
to a user based on their token scopes.
Args:
func: Function decorated with @require_scopes
user_scopes: Set of scopes the user possesses
Returns:
True if user has all required scopes (or no scopes required), False otherwise
Example:
```python
@require_scopes("notes:write")
async def create_note():
pass
user_scopes = {"notes:read", "notes:write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"notes:read"}
can_see = has_required_scopes(create_note, limited_user_scopes) # False
```
"""
required = get_required_scopes(func)
# No scopes required → always allow
if not required:
return True
# Empty user_scopes but scopes required → deny
if not user_scopes:
return False
# Check if user has all required scopes
return set(required).issubset(user_scopes)
-482
View File
@@ -1,482 +0,0 @@
"""Token verification using Nextcloud OIDC userinfo endpoint."""
import logging
import time
from typing import Any
import httpx
import jwt
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger(__name__)
class NextcloudTokenVerifier(TokenVerifier):
"""
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
This verifier supports both JWT and opaque tokens:
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
2. For opaque tokens: Falls back to userinfo endpoint validation
3. Caches successful responses to avoid repeated API calls/verifications
JWT validation provides:
- Faster validation (no HTTP call needed)
- Direct scope extraction from token payload
- Signature verification using JWKS
Userinfo fallback provides:
- Support for opaque tokens
- Backward compatibility
- Additional validation layer
"""
def __init__(
self,
nextcloud_host: str,
userinfo_uri: str,
jwks_uri: str | None = None,
issuer: str | None = None,
introspection_uri: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
cache_ttl: int = 3600,
):
"""
Initialize the token verifier.
Args:
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
userinfo_uri: Full URL to the userinfo endpoint
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
issuer: Expected issuer claim value (for JWT verification)
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
client_id: OAuth client ID (required for introspection)
client_secret: OAuth client secret (required for introspection)
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.userinfo_uri = userinfo_uri
self.jwks_uri = jwks_uri
self.issuer = issuer
self.introspection_uri = introspection_uri
self.client_id = client_id
self.client_secret = client_secret
self.cache_ttl = cache_ttl
# Cache: token -> (userinfo, expiry_timestamp)
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
# HTTP client for userinfo/introspection requests
self._client = httpx.AsyncClient(timeout=10.0)
# PyJWKClient for JWT verification (lazy initialization)
self._jwks_client: PyJWKClient | None = None
if jwks_uri:
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
# Introspection support
if introspection_uri and client_id and client_secret:
logger.info(f"Token introspection enabled: {introspection_uri}")
elif introspection_uri:
logger.warning(
"Introspection URI provided but missing client credentials - introspection disabled"
)
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
This method:
1. Checks the cache first for recent validations
2. Attempts JWT verification if JWKS is configured and token looks like JWT
3. Falls back to introspection for opaque tokens (if configured)
4. Falls back to userinfo endpoint as last resort
5. Returns AccessToken with username and scopes
Args:
token: The bearer token to verify
Returns:
AccessToken if valid, None if invalid or expired
"""
# Check cache first
cached = self._get_cached_token(token)
if cached:
logger.debug("Token found in cache")
return cached
# Try JWT verification first if enabled and token looks like JWT
is_jwt_format = self._is_jwt_format(token)
logger.debug(
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
)
if self._jwks_client and is_jwt_format:
logger.debug("Attempting JWT verification...")
jwt_result = self._verify_jwt(token)
if jwt_result:
logger.info("Token validated via JWT verification")
return jwt_result
else:
logger.warning("JWT verification failed, will try other methods")
# For opaque tokens, try introspection if available
if self.introspection_uri and self.client_id and self.client_secret:
logger.debug("Attempting token introspection...")
try:
introspection_result = await self._verify_via_introspection(token)
if introspection_result:
logger.info("Token validated via introspection")
return introspection_result
except Exception as e:
logger.warning(f"Introspection failed: {e}")
# Fall back to userinfo endpoint validation (last resort)
logger.debug("Attempting userinfo endpoint validation...")
try:
return await self._verify_via_userinfo(token)
except Exception as e:
logger.warning(f"Token verification failed: {e}")
return None
def _is_jwt_format(self, token: str) -> bool:
"""
Check if token looks like a JWT (has 3 parts separated by dots).
Args:
token: The token to check
Returns:
True if token appears to be JWT format
"""
return "." in token and token.count(".") == 2
def _verify_jwt(self, token: str) -> AccessToken | None:
"""
Verify JWT token with signature validation using JWKS.
Args:
token: The JWT token to verify
Returns:
AccessToken if valid, None if invalid
"""
try:
# Get signing key from JWKS
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Verify and decode JWT
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True if self.issuer else False,
"verify_aud": False, # Skip audience validation for Bearer tokens
},
)
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
logger.debug(f"Full JWT payload: {payload}")
# Extract username (sub claim)
username = payload.get("sub")
if not username:
logger.error("No 'sub' claim found in JWT payload")
return None
# Extract scopes from scope claim (space-separated string)
scope_string = payload.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
)
# Extract expiration
exp = payload.get("exp")
if not exp:
logger.warning("No 'exp' claim in JWT, using default TTL")
exp = int(time.time() + self.cache_ttl)
# Cache the result
userinfo = {
"sub": username,
"scope": scope_string,
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
}
self._token_cache[token] = (userinfo, exp)
return AccessToken(
token=token,
client_id=payload.get("client_id", ""),
scopes=scopes,
expires_at=exp,
resource=username, # Store username in resource field (RFC 8707)
)
except jwt.ExpiredSignatureError:
logger.info("JWT token has expired")
return None
except jwt.InvalidIssuerError as e:
logger.warning(f"JWT issuer validation failed: {e}")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"JWT validation failed: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during JWT verification: {e}")
return None
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
"""
Validate token by calling the introspection endpoint (RFC 7662).
This method validates opaque tokens and retrieves their scopes.
Args:
token: The bearer token to introspect
Returns:
AccessToken if active, None if inactive or invalid
"""
try:
# Introspection requires client authentication
response = await self._client.post(
self.introspection_uri,
data={"token": token},
auth=(self.client_id, self.client_secret),
)
if response.status_code == 200:
introspection_data = response.json()
# Check if token is active
if not introspection_data.get("active", False):
logger.info("Token introspection returned inactive=false")
return None
logger.debug(
f"Token introspected successfully for user: {introspection_data.get('sub')}"
)
# Extract username
username = introspection_data.get("sub") or introspection_data.get(
"username"
)
if not username:
logger.error("No username found in introspection response")
return None
# Extract scopes (space-separated string)
scope_string = introspection_data.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(f"Extracted scopes from introspection: {scopes}")
# Extract expiration
exp = introspection_data.get("exp")
if exp:
expiry = float(exp)
else:
logger.warning(
"No 'exp' in introspection response, using default TTL"
)
expiry = time.time() + self.cache_ttl
# Cache the result
cache_data = {
"sub": username,
"scope": scope_string,
**{
k: v
for k, v in introspection_data.items()
if k not in ["sub", "scope", "active"]
},
}
self._token_cache[token] = (cache_data, expiry)
return AccessToken(
token=token,
client_id=introspection_data.get("client_id", ""),
scopes=scopes,
expires_at=int(expiry),
resource=username,
)
elif response.status_code in (400, 401, 403):
logger.warning(
f"Token introspection failed: HTTP {response.status_code}. "
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
f"token issued to different OAuth client, (2) Expired client credentials, "
f"(3) Invalid token. Will fall back to userinfo endpoint. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
else:
logger.warning(
f"Unexpected response from introspection: {response.status_code}. "
f"Response: {response.text[:200] if response.text else 'empty'}"
)
return None
except httpx.TimeoutException:
logger.error("Timeout while introspecting token")
return None
except httpx.RequestError as e:
logger.error(f"Network error while introspecting token: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during token introspection: {e}")
return None
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
"""
Validate token by calling the userinfo endpoint.
Args:
token: The bearer token to verify
Returns:
AccessToken if valid, None otherwise
"""
try:
response = await self._client.get(
self.userinfo_uri, headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 200:
userinfo = response.json()
logger.debug(
f"Token validated successfully for user: {userinfo.get('sub')}"
)
# Cache the result
expiry = time.time() + self.cache_ttl
self._token_cache[token] = (userinfo, expiry)
# Create AccessToken with username in resource field (workaround for MCP SDK)
username = userinfo.get("sub") or userinfo.get("preferred_username")
if not username:
logger.error("No username found in userinfo response")
return None
return AccessToken(
token=token,
client_id="", # Not available from userinfo
scopes=self._extract_scopes(userinfo),
expires_at=int(expiry),
resource=username, # Store username in resource field (RFC 8707)
)
elif response.status_code in (400, 401, 403):
logger.info(f"Token validation failed: HTTP {response.status_code}")
return None
else:
logger.warning(
f"Unexpected response from userinfo: {response.status_code}"
)
return None
except httpx.TimeoutException:
logger.error("Timeout while validating token via userinfo endpoint")
return None
except httpx.RequestError as e:
logger.error(f"Network error while validating token: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during token validation: {e}")
return None
def _get_cached_token(self, token: str) -> AccessToken | None:
"""
Retrieve a token from cache if not expired.
Args:
token: The bearer token to look up
Returns:
AccessToken if cached and valid, None otherwise
"""
if token not in self._token_cache:
return None
userinfo, expiry = self._token_cache[token]
# Check if expired
if time.time() >= expiry:
logger.debug("Cached token expired, removing from cache")
del self._token_cache[token]
return None
# Return cached AccessToken
username = userinfo.get("sub") or userinfo.get("preferred_username")
return AccessToken(
token=token,
client_id="",
scopes=self._extract_scopes(userinfo),
expires_at=int(expiry),
resource=username,
)
def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]:
"""
Extract scopes from userinfo response.
First attempts to read actual scopes from the 'scope' field (RFC 8693).
If not present, infers scopes from the claims present in the response.
Args:
userinfo: The userinfo response dictionary
Returns:
List of scopes (actual or inferred)
"""
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
scope_string = userinfo.get("scope")
if scope_string:
scopes = scope_string.split() if isinstance(scope_string, str) else []
if scopes:
logger.debug(
f"Using actual scopes from userinfo: {scopes} (scope field present)"
)
return scopes
# Fallback: Infer scopes from claims present in response
# This maintains backward compatibility with OIDC providers that don't
# include the scope field in userinfo responses
logger.debug(
"No scope field in userinfo response, inferring scopes from claims"
)
scopes = ["openid"] # Always present
if "email" in userinfo:
scopes.append("email")
if any(
key in userinfo for key in ["name", "given_name", "family_name", "picture"]
):
scopes.append("profile")
if "roles" in userinfo:
scopes.append("roles")
if "groups" in userinfo:
scopes.append("groups")
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
return scopes
def clear_cache(self):
"""Clear the token cache."""
self._token_cache.clear()
logger.debug("Token cache cleared")
async def close(self):
"""Cleanup resources."""
await self._client.aclose()
logger.debug("Token verifier closed")
+6 -34
View File
@@ -2,25 +2,21 @@ import logging
import os
from httpx import (
AsyncBaseTransport,
AsyncClient,
AsyncHTTPTransport,
Auth,
BasicAuth,
Request,
Response,
AsyncBaseTransport,
AsyncHTTPTransport,
)
from ..controllers.notes_search import NotesSearchController
from .calendar import CalendarClient
from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
from .users import UsersClient
from .webdav import WebDAVClient
logger = logging.getLogger(__name__)
@@ -72,15 +68,9 @@ class NextcloudClient:
self.notes = NotesClient(self._client, username)
self.webdav = WebDAVClient(self._client, username)
self.tables = TablesClient(self._client, username)
self.calendar = CalendarClient(
base_url, username, auth
) # Uses AsyncDavClient internally
self.calendar = CalendarClient(self._client, username)
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(self._client, username)
# Initialize controllers
self._notes_search = NotesSearchController()
@@ -95,23 +85,6 @@ class NextcloudClient:
# Pass username to constructor
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
@classmethod
def from_token(cls, base_url: str, token: str, username: str):
"""Create NextcloudClient with OAuth bearer token.
Args:
base_url: Nextcloud base URL
token: OAuth access token
username: Nextcloud username
Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
async def capabilities(self):
response = await self._client.get(
"/ocs/v2.php/cloud/capabilities",
@@ -123,14 +96,13 @@ class NextcloudClient:
async def notes_search_notes(self, *, query: str):
"""Search notes using token-based matching with relevance ranking."""
all_notes = self.notes.get_all_notes()
return await self._notes_search.search_notes(all_notes, query)
all_notes = await self.notes.get_all_notes()
return self._notes_search.search_notes(all_notes, query)
def _get_webdav_base_path(self) -> str:
"""Helper to get the base WebDAV path for the authenticated user."""
return f"/remote.php/dav/files/{self.username}"
async def close(self):
"""Close the HTTP client and CalDAV client."""
"""Close the HTTP client."""
await self._client.aclose()
await self.calendar.close()
+3 -3
View File
@@ -1,11 +1,11 @@
"""Base client for Nextcloud operations with shared authentication."""
import logging
import time
from abc import ABC
from functools import wraps
from httpx import AsyncClient, HTTPStatusError, RequestError, codes
from functools import wraps
import time
from httpx import HTTPStatusError, codes, RequestError, AsyncClient
logger = logging.getLogger(__name__)
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -1,11 +1,9 @@
"""CardDAV client for NextCloud contacts operations."""
import logging
import xml.etree.ElementTree as ET
from pythonvCard4.vcard import Contact
from .base import BaseNextcloudClient
import xml.etree.ElementTree as ET
from pythonvCard4.vcard import Contact
logger = logging.getLogger(__name__)
-250
View File
@@ -1,250 +0,0 @@
"""Client for Nextcloud Cookbook app operations."""
import logging
from typing import Any, Dict, List
from httpx import Timeout
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
class CookbookClient(BaseNextcloudClient):
"""Client for Nextcloud Cookbook app operations."""
async def get_version(self) -> Dict[str, Any]:
"""Get Cookbook app and API version."""
response = await self._make_request("GET", "/apps/cookbook/api/version")
return response.json()
async def get_config(self) -> Dict[str, Any]:
"""Get current Cookbook app configuration."""
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
return response.json()
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Set Cookbook app configuration.
Args:
config: Configuration dictionary with fields like:
- folder: Recipe folder path
- update_interval: Auto-rescan interval in minutes
- print_image: Whether to print images with recipes
- visibleInfoBlocks: Visible info blocks configuration
Returns:
Response with status message
"""
response = await self._make_request(
"POST", "/apps/cookbook/api/v1/config", json=config
)
return response.json()
async def reindex(self) -> str:
"""Trigger a rescan of all recipes into the caching database.
Returns:
Success message
"""
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
return response.json()
async def list_recipes(self) -> List[Dict[str, Any]]:
"""Get all recipes in the database.
Returns:
List of recipe stubs with basic information
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
return response.json()
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
"""Get a single recipe by ID.
Args:
recipe_id: The recipe ID
Returns:
Full recipe data
"""
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
)
return response.json()
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
"""Create a new recipe.
Args:
recipe_data: Recipe data following schema.org/Recipe format.
Required: name
Optional: description, ingredients, instructions, etc.
Returns:
ID of the newly created recipe
"""
response = await self._make_request(
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
)
return response.json()
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
"""Update an existing recipe.
Args:
recipe_id: The recipe ID to update
recipe_data: Updated recipe data
Returns:
ID of the updated recipe
"""
response = await self._make_request(
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
)
return response.json()
async def delete_recipe(self, recipe_id: int) -> str:
"""Delete a recipe.
Args:
recipe_id: The recipe ID to delete
Returns:
Success message
"""
response = await self._make_request(
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
)
return response.json()
async def import_recipe(self, url: str) -> Dict[str, Any]:
"""Import a recipe from a URL using schema.org metadata.
Args:
url: URL of the recipe to import
Returns:
Full imported recipe data
"""
logger.info(f"Importing recipe from URL: {url}")
response = await self._make_request(
"POST",
"/apps/cookbook/api/v1/import",
json={"url": url},
timeout=Timeout(300.0),
)
return response.json()
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
"""Get the main image of a recipe.
Args:
recipe_id: The recipe ID
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
Returns:
Image bytes
"""
response = await self._make_request(
"GET",
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
params={"size": size},
)
return response.content
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
"""Search for recipes by keywords, tags, and categories.
Args:
query: Search string (URL-encoded, space/comma separated)
Returns:
List of matching recipe stubs
"""
# URL encode the query
from urllib.parse import quote
encoded_query = quote(query)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
)
return response.json()
async def list_categories(self) -> List[Dict[str, Any]]:
"""Get all known categories.
Note: A category name of '*' indicates recipes with no category.
Returns:
List of categories with recipe counts
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
return response.json()
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
"""Get all recipes in a specific category.
Args:
category: Category name (use "_" for recipes with no category)
Returns:
List of recipe stubs in the category
"""
from urllib.parse import quote
encoded_category = quote(category)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
)
return response.json()
async def rename_category(self, old_name: str, new_name: str) -> str:
"""Rename a category.
Args:
old_name: Current category name
new_name: New category name
Returns:
New category name
"""
from urllib.parse import quote
encoded_old_name = quote(old_name)
response = await self._make_request(
"PUT",
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
json={"name": new_name},
)
return response.json()
async def list_keywords(self) -> List[Dict[str, Any]]:
"""Get all known keywords/tags.
Returns:
List of keywords with recipe counts
"""
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
return response.json()
async def get_recipes_with_keywords(
self, keywords: List[str]
) -> List[Dict[str, Any]]:
"""Get all recipes associated with certain keywords.
Args:
keywords: List of keywords to filter by
Returns:
List of recipe stubs matching the keywords
"""
from urllib.parse import quote
# Join keywords with commas
keywords_str = ",".join(keywords)
encoded_keywords = quote(keywords_str)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
)
return response.json()
+11 -22
View File
@@ -1,16 +1,16 @@
from typing import Any, Dict, List, Optional
from typing import List, Optional, Dict, Any
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.deck import (
DeckBoard,
DeckStack,
DeckCard,
DeckLabel,
DeckACL,
DeckAttachment,
DeckBoard,
DeckCard,
DeckComment,
DeckConfig,
DeckLabel,
DeckSession,
DeckStack,
DeckConfig,
)
@@ -99,7 +99,7 @@ class DeckClient(BaseNextcloudClient):
permission_edit: bool,
permission_share: bool,
permission_manage: bool,
) -> DeckACL:
) -> List[DeckACL]:
json_data = {
"type": type,
"participant": participant,
@@ -107,14 +107,10 @@ class DeckClient(BaseNextcloudClient):
"permissionShare": permission_share,
"permissionManage": permission_manage,
}
headers = self._get_deck_headers()
response = await self._make_request(
"POST",
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
json=json_data,
headers=headers,
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data
)
return DeckACL(**response.json())
return [DeckACL(**acl) for acl in response.json()]
async def update_acl_rule(
self,
@@ -131,20 +127,13 @@ class DeckClient(BaseNextcloudClient):
json_data["permissionShare"] = permission_share
if permission_manage is not None:
json_data["permissionManage"] = permission_manage
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
json=json_data,
headers=headers,
"PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data
)
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
headers = self._get_deck_headers()
await self._make_request(
"DELETE",
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
headers=headers,
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}"
)
async def clone_board(
-151
View File
@@ -1,151 +0,0 @@
"""Nextcloud Groups API client."""
import logging
from typing import List
from .base import BaseNextcloudClient, retry_on_429
logger = logging.getLogger(__name__)
class GroupsClient(BaseNextcloudClient):
"""Client for Nextcloud Groups API operations."""
@retry_on_429
async def search_groups(
self,
search: str | None = None,
limit: int | None = None,
offset: int | None = None,
) -> List[str]:
"""
Search for groups on the Nextcloud server.
Args:
search: Optional search string to filter groups
limit: Optional limit for number of results
offset: Optional offset for pagination
Returns:
List of group IDs matching the search criteria
"""
params = {}
if search is not None:
params["search"] = search
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
response = await self._client.get(
"/ocs/v2.php/cloud/groups",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
groups = data["ocs"]["data"].get("groups", [])
return groups
@retry_on_429
async def create_group(self, groupid: str) -> None:
"""
Create a new group.
Args:
groupid: The group ID to create
Raises:
HTTPStatusError: If the request fails (e.g., group already exists)
"""
response = await self._client.post(
"/ocs/v2.php/cloud/groups",
data={"groupid": groupid},
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Created group: {groupid}")
@retry_on_429
async def delete_group(self, groupid: str) -> None:
"""
Delete a group.
Args:
groupid: The group ID to delete
Raises:
HTTPStatusError: If the request fails (e.g., group doesn't exist)
"""
response = await self._client.delete(
f"/ocs/v2.php/cloud/groups/{groupid}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Deleted group: {groupid}")
@retry_on_429
async def get_group_members(self, groupid: str) -> List[str]:
"""
Get members of a group.
Args:
groupid: The group ID
Returns:
List of usernames in the group
"""
response = await self._client.get(
f"/ocs/v2.php/cloud/groups/{groupid}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
users = data["ocs"]["data"].get("users", [])
return users
@retry_on_429
async def get_group_subadmins(self, groupid: str) -> List[str]:
"""
Get subadmins of a group.
Args:
groupid: The group ID
Returns:
List of usernames who are subadmins of the group
"""
response = await self._client.get(
f"/ocs/v2.php/cloud/groups/{groupid}/subadmins",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
# The API returns data as a list or dict depending on results
subadmins_data = data["ocs"]["data"]
if isinstance(subadmins_data, list):
return subadmins_data
return []
@retry_on_429
async def update_group_displayname(self, groupid: str, displayname: str) -> None:
"""
Update a group's display name.
Args:
groupid: The group ID
displayname: The new display name
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.put(
f"/ocs/v2.php/cloud/groups/{groupid}",
data={"key": "displayname", "value": displayname},
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
logger.info(f"Updated group {groupid} displayname to: {displayname}")
+8 -6
View File
@@ -1,7 +1,7 @@
"""Client for Nextcloud Notes app operations."""
import logging
from typing import Any, AsyncIterator, Dict, Optional
from typing import Any, Dict, List, Optional
from .base import BaseNextcloudClient
@@ -16,22 +16,24 @@ class NotesClient(BaseNextcloudClient):
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
return response.json()
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
"""Get all notes, yielding them one at a time."""
async def get_all_notes(self) -> List[Dict[str, Any]]:
"""Get all notes."""
notes = []
cursor = ""
while True:
response = await self._make_request(
"GET",
"/apps/notes/api/v1/notes",
params={"chunkSize": 10, "chunkCursor": cursor},
params={"chunkSize": 50, "chunkCursor": cursor},
)
for note in response.json():
yield note
notes.extend(response.json())
if "X-Notes-Chunk-Cursor" not in response.headers:
break
cursor = response.headers["X-Notes-Chunk-Cursor"]
return notes
async def get_note(self, note_id: int) -> Dict[str, Any]:
"""Get a specific note by ID."""
response = await self._make_request(
-208
View File
@@ -1,208 +0,0 @@
"""Nextcloud OCS Sharing API client for file/folder sharing operations."""
import logging
from typing import Any
from .base import BaseNextcloudClient, retry_on_429
logger = logging.getLogger(__name__)
class SharingClient(BaseNextcloudClient):
"""Client for Nextcloud OCS Sharing API operations."""
@retry_on_429
async def create_share(
self,
path: str,
share_with: str,
share_type: int = 0,
permissions: int = 1,
) -> dict[str, Any]:
"""Create a share for a file or folder.
Args:
path: Path to file/folder to share (relative to user's files)
share_with: Username (for user share) or group name (for group share)
share_type: Share type (0=user, 1=group, 3=public link)
permissions: Share permissions:
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common combinations: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
Share data including share ID
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.post(
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
data={
"path": path,
"shareType": share_type,
"shareWith": share_with,
"permissions": permissions,
},
)
response.raise_for_status()
data = response.json()
# OCS API v2 uses HTTP-style status codes (200 for success)
# OCS API v1 used custom codes (100 for success)
ocs_status = data["ocs"]["meta"]["statuscode"]
if ocs_status not in (100, 200):
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
raise RuntimeError(f"OCS API error (code {ocs_status}): {ocs_message}")
share_data = data["ocs"]["data"]
# Handle case where data might be an empty list on error
if not share_data or (isinstance(share_data, list) and len(share_data) == 0):
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
raise RuntimeError(
f"Share creation failed: {ocs_message} (status {ocs_status})"
)
logger.info(
f"Created share {share_data['id']}: {path} -> {share_with} "
f"(type={share_type}, permissions={permissions})"
)
return share_data
@retry_on_429
async def delete_share(self, share_id: int) -> None:
"""Delete a share by its ID.
Args:
share_id: The share ID to delete
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.delete(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
logger.info(f"Deleted share {share_id}")
@retry_on_429
async def get_share(self, share_id: int) -> dict[str, Any]:
"""Get information about a specific share.
Args:
share_id: The share ID
Returns:
Share data
Raises:
HTTPStatusError: If the request fails
"""
response = await self._client.get(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
share_data = data["ocs"]["data"]
# The API returns a list with a single share, extract the first element
if isinstance(share_data, list) and len(share_data) > 0:
return share_data[0]
return share_data
@retry_on_429
async def list_shares(
self, path: str | None = None, shared_with_me: bool = False
) -> list[dict[str, Any]]:
"""List shares.
Args:
path: Optional path to filter shares for a specific file/folder
shared_with_me: If True, list shares shared with the current user
Returns:
List of share data
Raises:
HTTPStatusError: If the request fails
"""
params = {}
if path:
params["path"] = path
if shared_with_me:
params["shared_with_me"] = "true"
response = await self._client.get(
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()
data = response.json()
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
)
# Handle both single share and list of shares
shares_data = data["ocs"]["data"]
if isinstance(shares_data, dict):
return [shares_data]
return shares_data if shares_data else []
@retry_on_429
async def update_share(
self, share_id: int, permissions: int | None = None
) -> dict[str, Any]:
"""Update a share's permissions.
Args:
share_id: The share ID to update
permissions: New permissions value (see create_share for values)
Returns:
Updated share data
Raises:
HTTPStatusError: If the request fails
"""
data = {}
if permissions is not None:
data["permissions"] = permissions
response = await self._client.put(
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
data=data,
)
response.raise_for_status()
result = response.json()
if result["ocs"]["meta"]["statuscode"] not in (100, 200):
raise RuntimeError(
f"OCS API error: {result['ocs']['meta'].get('message', 'Unknown error')}"
)
logger.info(f"Updated share {share_id}")
return result["ocs"]["data"]
-223
View File
@@ -1,223 +0,0 @@
from typing import Dict, List, Optional
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.users import UserDetails
class UsersClient(BaseNextcloudClient):
"""Client for Nextcloud User API operations."""
def _get_user_headers(
self, additional_headers: Optional[Dict[str, str]] = None
) -> Dict[str, str]:
"""Get standard headers required for User API calls."""
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
if additional_headers:
headers.update(additional_headers)
return headers
async def create_user(
self,
userid: str,
password: Optional[str] = None,
display_name: Optional[str] = None,
email: Optional[str] = None,
groups: Optional[List[str]] = None,
subadmin_groups: Optional[List[str]] = None,
quota: Optional[str] = None,
language: Optional[str] = None,
) -> None:
"""
Create a new user on the Nextcloud server.
"""
data = {"userid": userid}
if password is not None:
data["password"] = password
if display_name is not None:
data["displayName"] = display_name
if email is not None:
data["email"] = email
if groups is not None:
for i, group in enumerate(groups):
data[f"groups[{i}]"] = group
if subadmin_groups is not None:
for i, group in enumerate(subadmin_groups):
data[f"subadmin[{i}]"] = group
if quota is not None:
data["quota"] = quota
if language is not None:
data["language"] = language
headers = self._get_user_headers()
await self._make_request(
"POST", "/ocs/v2.php/cloud/users", data=data, headers=headers
)
async def search_users(
self,
search: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
) -> List[str]:
"""
Retrieves a list of users from the Nextcloud server.
"""
params = {}
if search is not None:
params["search"] = search
if limit is not None:
params["limit"] = limit
if offset is not None:
params["offset"] = offset
headers = self._get_user_headers()
response = await self._make_request(
"GET", "/ocs/v2.php/cloud/users", params=params, headers=headers
)
# The v2 API returns JSON with users as a direct list under data.users
data = response.json()["ocs"]["data"]
return data.get("users", [])
async def get_user_details(self, userid: str) -> UserDetails:
"""
Retrieves information about a single user.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
)
return UserDetails(**response.json()["ocs"]["data"])
async def update_user_field(self, userid: str, key: str, value: str) -> None:
"""
Edits attributes related to a user.
"""
data = {"key": key, "value": value}
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}", data=data, headers=headers
)
async def get_editable_user_fields(self) -> List[str]:
"""
Gets the list of editable data fields for a user.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", "/ocs/v2.php/cloud/user/fields", headers=headers
)
# The v2 API returns data as a direct list
data = response.json()["ocs"]["data"]
return data if isinstance(data, list) else []
async def disable_user(self, userid: str) -> None:
"""
Disables a user on the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}/disable", headers=headers
)
async def enable_user(self, userid: str) -> None:
"""
Enables a user on the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"PUT", f"/ocs/v2.php/cloud/users/{userid}/enable", headers=headers
)
async def delete_user(self, userid: str) -> None:
"""
Deletes a user from the Nextcloud server.
"""
headers = self._get_user_headers()
await self._make_request(
"DELETE", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
)
async def get_user_groups(self, userid: str) -> List[str]:
"""
Retrieves a list of groups the specified user is a member of.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}/groups", headers=headers
)
# The v2 API returns groups as a direct list under data.groups
data = response.json()["ocs"]["data"]
return data.get("groups", [])
async def add_user_to_group(self, userid: str, groupid: str) -> None:
"""
Adds the specified user to the specified group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"POST",
f"/ocs/v2.php/cloud/users/{userid}/groups",
data=data,
headers=headers,
)
async def remove_user_from_group(self, userid: str, groupid: str) -> None:
"""
Removes the specified user from the specified group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"DELETE",
f"/ocs/v2.php/cloud/users/{userid}/groups",
data=data,
headers=headers,
)
async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None:
"""
Makes a user the subadmin of a group.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"POST",
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
data=data,
headers=headers,
)
async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None:
"""
Removes the subadmin rights for the user specified from the group specified.
"""
data = {"groupid": groupid}
headers = self._get_user_headers()
await self._make_request(
"DELETE",
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
data=data,
headers=headers,
)
async def get_user_subadmin_groups(self, userid: str) -> List[str]:
"""
Returns the groups in which the user is a subadmin.
"""
headers = self._get_user_headers()
response = await self._make_request(
"GET", f"/ocs/v2.php/cloud/users/{userid}/subadmins", headers=headers
)
# The v2 API returns data as a direct list
data = response.json()["ocs"]["data"]
return data if isinstance(data, list) else []
async def resend_welcome_email(self, userid: str) -> None:
"""
Triggers the welcome email for this user again.
"""
headers = self._get_user_headers()
await self._make_request(
"POST", f"/ocs/v2.php/cloud/users/{userid}/welcome", headers=headers
)
-376
View File
@@ -570,379 +570,3 @@ class WebDAVClient(BaseNextcloudClient):
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
)
raise e
async def search_files(
self,
scope: str = "",
where_conditions: Optional[str] = None,
properties: Optional[List[str]] = None,
order_by: Optional[List[Tuple[str, str]]] = None,
limit: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Search for files using WebDAV SEARCH method (RFC 5323).
Args:
scope: Directory path to search in (empty string for user root)
where_conditions: XML string for where clause conditions
properties: List of property names to retrieve (defaults to basic set)
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
limit: Maximum number of results to return
Returns:
List of file/directory dictionaries with requested properties
"""
# Default properties if not specified
if properties is None:
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
]
# Build the SEARCH request XML
search_body = self._build_search_xml(
scope=scope,
where_conditions=where_conditions,
properties=properties,
order_by=order_by,
limit=limit,
)
# The SEARCH endpoint is at the dav root
search_path = "/remote.php/dav/"
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
logger.debug(f"Searching files in scope: {scope}")
try:
response = await self._make_request(
"SEARCH", search_path, content=search_body, headers=headers
)
response.raise_for_status()
# Parse the XML response
results = self._parse_search_response(response.content, scope)
logger.debug(f"Search returned {len(results)} results")
return results
except HTTPStatusError as e:
logger.error(f"HTTP error during search: {e}")
raise e
except Exception as e:
logger.error(f"Unexpected error during search: {e}")
raise e
def _build_search_xml(
self,
scope: str,
where_conditions: Optional[str],
properties: List[str],
order_by: Optional[List[Tuple[str, str]]],
limit: Optional[int],
) -> str:
"""Build the XML body for a SEARCH request."""
# Construct the scope path
username = self.username
scope_path = f"/files/{username}"
if scope:
scope_path = f"{scope_path}/{scope.lstrip('/')}"
# Build property list
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
# Build where clause
where_xml = where_conditions if where_conditions else ""
# Build order by clause
orderby_xml = ""
if order_by:
order_elements = []
for prop, direction in order_by:
prop_element = self._property_to_xml(prop)
dir_element = (
"<d:ascending/>"
if direction.lower() == "ascending"
else "<d:descending/>"
)
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
orderby_xml = "\n".join(order_elements)
else:
orderby_xml = ""
# Build limit clause
limit_xml = (
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
)
# Construct the full SEARCH XML
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:basicsearch>
<d:select>
<d:prop>
{prop_xml}
</d:prop>
</d:select>
<d:from>
<d:scope>
<d:href>{scope_path}</d:href>
<d:depth>infinity</d:depth>
</d:scope>
</d:from>
<d:where>
{where_xml}
</d:where>
<d:orderby>
{orderby_xml}
</d:orderby>
{limit_xml}
</d:basicsearch>
</d:searchrequest>"""
return search_xml
def _property_to_xml(self, prop: str) -> str:
"""Convert a property name to its XML element."""
# Handle properties with namespace prefixes
if prop.startswith("{"):
# Already a full namespace
namespace_end = prop.index("}")
namespace = prop[1:namespace_end]
local_name = prop[namespace_end + 1 :]
# Map namespace URIs to prefixes
ns_map = {
"DAV:": "d",
"http://owncloud.org/ns": "oc",
"http://nextcloud.org/ns": "nc",
}
prefix = ns_map.get(namespace, "d")
return f"<{prefix}:{local_name}/>"
else:
# Guess namespace based on common properties
if prop in [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"quota-available-bytes",
"quota-used-bytes",
]:
return f"<d:{prop}/>"
elif prop in [
"fileid",
"size",
"permissions",
"favorite",
"tags",
"owner-id",
"owner-display-name",
"share-types",
"checksums",
"comments-count",
"comments-unread",
]:
return f"<oc:{prop}/>"
else:
# Assume nc namespace for newer properties
return f"<nc:{prop}/>"
def _parse_search_response(
self, xml_content: bytes, scope: str
) -> List[Dict[str, Any]]:
"""Parse the XML response from a SEARCH request."""
root = ET.fromstring(xml_content)
items = []
# Process each response element
responses = root.findall(".//{DAV:}response")
for response_elem in responses:
href = response_elem.find(".//{DAV:}href")
if href is None:
continue
# Extract file/directory path from href
href_text = href.text or ""
# Remove the /remote.php/dav/files/username/ prefix to get relative path
path_parts = href_text.split("/files/")
if len(path_parts) > 1:
# Get the path after username
path_after_user = "/".join(path_parts[1].split("/")[1:])
relative_path = path_after_user.rstrip("/")
else:
relative_path = href_text.rstrip("/").split("/")[-1]
# Get properties
propstat = response_elem.find(".//{DAV:}propstat")
if propstat is None:
continue
prop = propstat.find(".//{DAV:}prop")
if prop is None:
continue
# Build item dictionary
item = {"path": relative_path, "href": href_text}
# Extract all properties
for child in prop:
tag = child.tag
value = child.text
# Remove namespace from tag
if "}" in tag:
tag = tag.split("}", 1)[1]
# Handle special properties
if tag == "resourcetype":
item["is_directory"] = child.find(".//{DAV:}collection") is not None
elif tag == "getcontentlength":
item["size"] = int(value) if value else 0
elif tag == "displayname":
item["name"] = value
elif tag == "getcontenttype":
item["content_type"] = value
elif tag == "getlastmodified":
item["last_modified"] = value
elif tag == "getetag":
item["etag"] = value.strip('"') if value else None
elif tag == "fileid":
item["file_id"] = int(value) if value else None
elif tag == "favorite":
item["is_favorite"] = value == "1"
elif tag == "permissions":
item["permissions"] = value
elif tag == "size":
# oc:size includes folder sizes
item["total_size"] = int(value) if value else 0
else:
# Store other properties as-is
item[tag] = value
items.append(item)
return items
async def find_by_name(
self, pattern: str, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find files by name pattern using LIKE matching.
Args:
pattern: Name pattern to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of matching files/directories
Examples:
# Find all .txt files
results = await find_by_name("%.txt")
# Find files starting with "report"
results = await find_by_name("report%")
"""
where_conditions = f"""
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>{pattern}</d:literal>
</d:like>
"""
return await self.search_files(
scope=scope, where_conditions=where_conditions, limit=limit
)
async def find_by_type(
self, mime_type: str, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Find files by MIME type.
Args:
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of matching files
Examples:
# Find all images
results = await find_by_type("image/%")
# Find all PDFs
results = await find_by_type("application/pdf")
"""
where_conditions = f"""
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>{mime_type}</d:literal>
</d:like>
"""
return await self.search_files(
scope=scope, where_conditions=where_conditions, limit=limit
)
async def list_favorites(
self, scope: str = "", limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""List all favorite files.
Args:
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
List of favorite files/directories
Examples:
# List all favorites
results = await list_favorites()
# List favorites in a specific folder
results = await list_favorites(scope="Documents")
"""
# Use REPORT method for favorites as it's more efficient
# But we can also use SEARCH as fallback
where_conditions = """
<d:eq>
<d:prop>
<oc:favorite/>
</d:prop>
<d:literal>1</d:literal>
</d:eq>
"""
# Request favorite property
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"fileid",
"favorite",
]
return await self.search_files(
scope=scope,
where_conditions=where_conditions,
properties=properties,
limit=limit,
)
+2 -18
View File
@@ -2,18 +2,17 @@ import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "http",
},
}
},
"formatters": {
"http": {
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
}
},
"loggers": {
"": {
@@ -30,21 +29,6 @@ LOGGING_CONFIG = {
"level": "INFO",
"propagate": False, # Prevent propagation to root logger
},
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"uvicorn.access": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
},
}
-51
View File
@@ -1,51 +0,0 @@
"""Helper functions for accessing context in MCP tools."""
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.client import NextcloudClient
def get_client(ctx: Context) -> NextcloudClient:
"""
Get the appropriate Nextcloud client based on authentication mode.
In BasicAuth mode, returns the shared client from lifespan context.
In OAuth mode, creates a new client per-request using the OAuth context.
This function automatically detects the authentication mode by checking
the type of the lifespan context.
Args:
ctx: MCP request context
Returns:
NextcloudClient configured for the current authentication mode
Raises:
AttributeError: If context doesn't contain expected data
Example:
```python
@mcp.tool()
async def my_tool(ctx: Context):
client = get_client(ctx)
return await client.capabilities()
```
"""
lifespan_ctx = ctx.request_context.lifespan_context
# Try BasicAuth mode first (has 'client' attribute)
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
from nextcloud_mcp_server.auth import get_client_from_context
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Unknown context type
raise AttributeError(
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
f"Type: {type(lifespan_ctx)}"
)
@@ -1,13 +1,13 @@
"""Controller for notes search functionality."""
from typing import Any, AsyncIterable, Dict, List
from typing import Any, Dict, List
class NotesSearchController:
"""Handles notes search logic and scoring."""
async def search_notes(
self, notes: AsyncIterable[Dict[str, Any]], query: str
def search_notes(
self, notes: List[Dict[str, Any]], query: str
) -> List[Dict[str, Any]]:
"""
Search notes using token-based matching with relevance ranking.
@@ -21,7 +21,7 @@ class NotesSearchController:
return []
# Process and score each note
async for note in notes:
for note in notes:
title_tokens, content_tokens = self._process_note_content(note)
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
+41 -43
View File
@@ -1,25 +1,41 @@
"""Pydantic models for structured MCP server responses."""
# Base models
from .base import BaseResponse, IdResponse, StatusResponse
from .base import (
BaseResponse,
IdResponse,
StatusResponse,
)
# Notes models
from .notes import (
Note,
NoteSearchResult,
NotesSettings,
CreateNoteResponse,
UpdateNoteResponse,
DeleteNoteResponse,
AppendContentResponse,
SearchNotesResponse,
)
# Calendar models
from .calendar import (
AvailabilitySlot,
BulkOperationResponse,
BulkOperationResult,
Calendar,
CalendarEvent,
CalendarEventSummary,
CreateEventResponse,
CreateMeetingResponse,
DeleteEventResponse,
FindAvailabilityResponse,
ListCalendarsResponse,
ListEventsResponse,
ManageCalendarResponse,
UpcomingEventsResponse,
UpdateEventResponse,
DeleteEventResponse,
ListEventsResponse,
ListCalendarsResponse,
AvailabilitySlot,
FindAvailabilityResponse,
BulkOperationResult,
BulkOperationResponse,
CreateMeetingResponse,
UpcomingEventsResponse,
ManageCalendarResponse,
)
# Contacts models
@@ -27,53 +43,38 @@ from .contacts import (
AddressBook,
Contact,
ContactField,
CreateAddressBookResponse,
CreateContactResponse,
DeleteAddressBookResponse,
DeleteContactResponse,
ListAddressBooksResponse,
ListContactsResponse,
CreateContactResponse,
UpdateContactResponse,
)
# Notes models
from .notes import (
AppendContentResponse,
CreateNoteResponse,
DeleteNoteResponse,
Note,
NoteSearchResult,
NotesSettings,
SearchNotesResponse,
UpdateNoteResponse,
DeleteContactResponse,
CreateAddressBookResponse,
DeleteAddressBookResponse,
)
# Tables models
from .tables import (
CreateRowResponse,
DeleteRowResponse,
GetSchemaResponse,
ListTablesResponse,
ReadTableResponse,
Table,
TableColumn,
TableRow,
TableSchema,
TableView,
TableSchema,
ListTablesResponse,
GetSchemaResponse,
ReadTableResponse,
CreateRowResponse,
UpdateRowResponse,
DeleteRowResponse,
)
# WebDAV models
from .webdav import (
CopyResourceResponse,
FileInfo,
DirectoryListing,
ReadFileResponse,
WriteFileResponse,
CreateDirectoryResponse,
DeleteResourceResponse,
DirectoryListing,
FileInfo,
MoveResourceResponse,
ReadFileResponse,
SearchFilesResponse,
WriteFileResponse,
)
__all__ = [
@@ -136,7 +137,4 @@ __all__ = [
"WriteFileResponse",
"CreateDirectoryResponse",
"DeleteResourceResponse",
"MoveResourceResponse",
"CopyResourceResponse",
"SearchFilesResponse",
]
-68
View File
@@ -180,71 +180,3 @@ class ManageCalendarResponse(BaseResponse):
None, description="List of calendars (for list action)"
)
message: str = Field(description="Success message")
# ============= Todo/Task Models =============
class Todo(BaseModel):
"""Model for a CalDAV todo/task (VTODO)."""
uid: str = Field(description="Todo UID")
summary: str = Field(description="Todo summary/title")
description: str = Field(default="", description="Todo description")
status: str = Field(
default="NEEDS-ACTION",
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
)
priority: int = Field(
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
)
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
completed: Optional[str] = Field(
None, description="Completion timestamp (ISO format)"
)
categories: str = Field(default="", description="Comma-separated categories")
href: str = Field(default="", description="CalDAV href")
etag: str = Field(default="", description="ETag for versioning")
calendar_name: Optional[str] = Field(
None, description="Calendar containing this todo"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this todo"
)
class ListTodosResponse(BaseResponse):
"""Response model for listing todos."""
todos: List[Todo] = Field(description="List of todos/tasks")
calendar_name: Optional[str] = Field(
None, description="Calendar name (if filtered to one calendar)"
)
total_count: int = Field(description="Total number of todos found")
class CreateTodoResponse(BaseResponse):
"""Response model for todo creation."""
todo: Todo = Field(description="The created todo")
calendar_name: str = Field(
description="Name of the calendar the todo was created in"
)
class UpdateTodoResponse(BaseResponse):
"""Response model for todo updates."""
todo: Todo = Field(description="The updated todo")
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
class DeleteTodoResponse(StatusResponse):
"""Response model for todo deletion."""
deleted_uid: str = Field(description="UID of the deleted todo")
calendar_name: str = Field(
description="Name of the calendar the todo was deleted from"
)
-216
View File
@@ -1,216 +0,0 @@
"""Pydantic models for Cookbook app responses."""
from typing import List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
from .base import BaseResponse, IdResponse, StatusResponse
class Nutrition(BaseModel):
"""Nutrition information following schema.org/NutritionInformation."""
type: str = Field(
default="NutritionInformation",
alias="@type",
description="Schema.org object type",
)
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
carbohydrateContent: Optional[str] = Field(
None, description="Carbohydrates (e.g., '300 g')"
)
cholesterolContent: Optional[str] = Field(
None, description="Cholesterol (e.g., '10 g')"
)
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
saturatedFatContent: Optional[str] = Field(
None, description="Saturated fat (e.g., '5 g')"
)
servingSize: Optional[str] = Field(
None, description="Serving size description (e.g., 'One plate')"
)
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
unsaturatedFatContent: Optional[str] = Field(
None, description="Unsaturated fat (e.g., '40 g')"
)
model_config = ConfigDict(populate_by_name=True)
class RecipeStub(BaseModel):
"""Stub of a recipe with basic information."""
id: str = Field(description="Recipe ID as string")
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
name: str = Field(description="Recipe name")
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
dateCreated: str = Field(description="Creation date (ISO8601)")
dateModified: Optional[str] = Field(
None, description="Last modified date (ISO8601)"
)
imageUrl: str = Field(default="", description="URL of the recipe image")
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
class Recipe(BaseModel):
"""Full recipe following schema.org/Recipe specification."""
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
id: Optional[str] = Field(None, description="Recipe ID")
name: str = Field(description="Recipe name")
description: str = Field(default="", description="Recipe description")
url: str = Field(default="", description="Original recipe URL")
image: str = Field(default="", description="URL of original recipe image")
imageUrl: Optional[str] = Field(
None, description="URL of the recipe image in Nextcloud"
)
imagePlaceholderUrl: Optional[str] = Field(
None, description="URL of placeholder image"
)
keywords: str = Field(default="", description="Comma-separated keywords")
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
dateModified: Optional[str] = Field(
None, description="Last modified date (ISO8601)"
)
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
recipeCategory: str = Field(default="", description="Recipe category")
tool: List[str] = Field(default_factory=list, description="Required tools")
recipeIngredient: List[str] = Field(
default_factory=list, description="List of ingredients"
)
recipeInstructions: List[str] = Field(
default_factory=list, description="Cooking instructions"
)
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
model_config = ConfigDict(populate_by_name=True, extra="allow")
class Category(BaseModel):
"""A recipe category."""
name: str = Field(description="Category name")
recipe_count: int = Field(description="Number of recipes in category")
class Keyword(BaseModel):
"""A recipe keyword/tag."""
name: str = Field(description="Keyword name")
recipe_count: int = Field(description="Number of recipes with this keyword")
class VisibleInfoBlocks(BaseModel):
"""Configuration for visible information blocks in the UI."""
preparation_time: Optional[bool] = Field(
None, alias="preparation-time", description="Show preparation time"
)
cooking_time: Optional[bool] = Field(
None, alias="cooking-time", description="Show cooking time"
)
total_time: Optional[bool] = Field(
None, alias="total-time", description="Show total time"
)
nutrition_information: Optional[bool] = Field(
None, alias="nutrition-information", description="Show nutrition info"
)
tools: Optional[bool] = Field(None, description="Show tools list")
model_config = ConfigDict(populate_by_name=True)
class CookbookConfig(BaseModel):
"""Cookbook app configuration."""
folder: Optional[str] = Field(None, description="Recipe folder path")
update_interval: Optional[int] = Field(
None, description="Auto-rescan interval in minutes"
)
print_image: Optional[bool] = Field(None, description="Print images with recipes")
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
None, description="Visible info blocks configuration"
)
class APIVersion(BaseModel):
"""API version information."""
epoch: int = Field(description="API epoch")
major: int = Field(description="Major version")
minor: int = Field(description="Minor version")
class Version(BaseModel):
"""Version information for Cookbook app and API."""
cookbook_version: List[int] = Field(description="Cookbook app version")
api_version: APIVersion = Field(description="API version")
# Response models for MCP tools
class ImportRecipeResponse(BaseResponse):
"""Response model for recipe import."""
recipe: Recipe = Field(description="The imported recipe")
recipe_id: str = Field(description="ID of the imported recipe")
class CreateRecipeResponse(IdResponse):
"""Response model for recipe creation."""
pass
class UpdateRecipeResponse(IdResponse):
"""Response model for recipe update."""
pass
class DeleteRecipeResponse(StatusResponse):
"""Response model for recipe deletion."""
deleted_id: int = Field(description="ID of deleted recipe")
class ListRecipesResponse(BaseResponse):
"""Response model for listing recipes."""
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
total_count: int = Field(description="Total number of recipes")
class SearchRecipesResponse(BaseResponse):
"""Response model for recipe search."""
recipes: List[RecipeStub] = Field(description="Matching recipes")
query: str = Field(description="Search query used")
total_found: int = Field(description="Number of recipes found")
class ListCategoriesResponse(BaseResponse):
"""Response model for listing categories."""
categories: List[Category] = Field(description="List of categories")
class ListKeywordsResponse(BaseResponse):
"""Response model for listing keywords."""
keywords: List[Keyword] = Field(description="List of keywords")
class ReindexResponse(StatusResponse):
"""Response model for reindex operation."""
pass
+1 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, field_validator
-41
View File
@@ -1,41 +0,0 @@
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
class User(BaseModel):
"""Model for creating a new user."""
userid: str
password: Optional[str] = None
displayName: Optional[str] = None
email: Optional[str] = None
groups: Optional[List[str]] = Field(default_factory=list)
subadmin: Optional[List[str]] = Field(default_factory=list)
quota: Optional[str] = None
language: Optional[str] = None
class UserDetails(BaseModel):
"""Model for retrieving detailed user information."""
model_config = ConfigDict(populate_by_name=True)
enabled: bool
id: str
quota: Union[str, Dict[str, Any]] # Can be string or quota object
email: Optional[str] = None # Can be null
displayname: str = Field(
alias="display-name"
) # Handle both displayname and display-name
phone: Optional[str] = None
address: Optional[str] = None
website: Optional[str] = None
twitter: Optional[str] = None
groups: Optional[List[str]] = Field(default_factory=list)
class Group(BaseModel):
"""Model for a user group."""
id: str
+1 -14
View File
@@ -22,8 +22,6 @@ class FileInfo(BaseModel):
None, description="Last modification time (ISO format)"
)
etag: Optional[str] = Field(None, description="ETag for versioning")
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
@property
def last_modified_datetime(self) -> Optional[datetime]:
@@ -40,7 +38,7 @@ class DirectoryListing(BaseResponse):
"""Response model for directory listings."""
path: str = Field(description="Directory path")
files: List[FileInfo] = Field(description="Files and directories in the path")
items: List[FileInfo] = Field(description="Files and directories in the path")
total_count: int = Field(description="Total number of items")
directories_count: int = Field(description="Number of directories")
files_count: int = Field(description="Number of files")
@@ -108,14 +106,3 @@ class CopyResourceResponse(StatusResponse):
overwrite: bool = Field(
description="Whether the destination was overwritten if it existed"
)
class SearchFilesResponse(BaseResponse):
"""Response model for WebDAV search operations."""
results: List[FileInfo] = Field(description="Search results")
total_found: int = Field(description="Total number of files found")
scope: str = Field(description="The scope/path that was searched")
filters_applied: Optional[dict] = Field(
None, description="Filters that were applied to the search"
)
+2 -6
View File
@@ -1,19 +1,15 @@
from .calendar import configure_calendar_tools
from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools
from .notes import configure_notes_tools
from .sharing import configure_sharing_tools
from .tables import configure_tables_tools
from .webdav import configure_webdav_tools
from .contacts import configure_contacts_tools
from .deck import configure_deck_tools
__all__ = [
"configure_calendar_tools",
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_notes_tools",
"configure_sharing_tools",
"configure_tables_tools",
"configure_webdav_tools",
]
+12 -237
View File
@@ -4,13 +4,10 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.calendar import (
Calendar,
ListCalendarsResponse,
ListTodosResponse,
Todo,
)
logger = logging.getLogger(__name__)
@@ -19,17 +16,15 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
@require_scopes("calendar:read")
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
calendars_data = await client.calendar.list_calendars()
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_create_event(
calendar_name: str,
title: str,
@@ -79,7 +74,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
Dict with event creation result
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
event_data = {
"title": title,
@@ -105,7 +100,6 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("calendar:read")
async def nc_calendar_list_events(
calendar_name: str,
ctx: Context,
@@ -139,7 +133,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
List of events matching the filters
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Convert YYYY-MM-DD format dates to datetime objects
start_datetime = None
@@ -207,19 +201,17 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool()
@require_scopes("calendar:read")
async def nc_calendar_get_event(
calendar_name: str,
event_uid: str,
ctx: Context,
):
"""Get detailed information about a specific event"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
return event_data
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_update_event(
calendar_name: str,
event_uid: str,
@@ -248,7 +240,7 @@ def configure_calendar_tools(mcp: FastMCP):
etag: str = "",
):
"""Update any aspect of an existing event"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Build update data with only non-None values
event_data = {}
@@ -292,18 +284,16 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_delete_event(
calendar_name: str,
event_uid: str,
ctx: Context,
):
"""Delete a calendar event"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_create_meeting(
title: str,
date: str,
@@ -342,7 +332,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
Dict with meeting creation result
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Combine date and time for start_datetime
start_datetime = f"{date}T{time}:00"
@@ -369,7 +359,6 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("calendar:read")
async def nc_calendar_get_upcoming_events(
ctx: Context,
calendar_name: str = "", # Empty = all calendars
@@ -377,7 +366,7 @@ def configure_calendar_tools(mcp: FastMCP):
limit: int = 10,
):
"""Get upcoming events in next N days"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
now = dt.datetime.now()
end_datetime = now + dt.timedelta(days=days_ahead)
@@ -419,7 +408,6 @@ def configure_calendar_tools(mcp: FastMCP):
return all_events[:limit]
@mcp.tool()
@require_scopes("calendar:read")
async def nc_calendar_find_availability(
duration_minutes: int,
ctx: Context,
@@ -447,7 +435,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
List of available time slots with start/end times and duration
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Parse attendees
attendee_list = []
@@ -499,7 +487,6 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_bulk_operations(
operation: str, # "update", "delete", "move"
ctx: Context,
@@ -549,7 +536,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
Summary of operation results including counts and details
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
if operation not in ["update", "delete", "move"]:
raise ValueError("Operation must be 'update', 'delete', or 'move'")
@@ -748,7 +735,6 @@ def configure_calendar_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("calendar:write")
async def nc_calendar_manage_calendar(
action: str, # "create", "delete", "update", "list"
ctx: Context,
@@ -772,7 +758,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns:
Result of the calendar management operation
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
if action == "list":
return await client.calendar.list_calendars()
@@ -813,214 +799,3 @@ def configure_calendar_tools(mcp: FastMCP):
else:
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
# ============= Todo/Task Tools =============
@mcp.tool()
@require_scopes("todo:read", "calendar:read")
async def nc_calendar_list_todos(
calendar_name: str,
ctx: Context,
status: Optional[str] = None,
min_priority: Optional[int] = None,
categories: Optional[str] = None,
summary_contains: Optional[str] = None,
) -> ListTodosResponse:
"""List todos/tasks in a calendar with optional filtering.
Args:
calendar_name: Name of the calendar to list todos from
ctx: MCP context
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
min_priority: Filter by minimum priority (1=highest, 9=lowest)
categories: Filter by categories (comma-separated, e.g., "work,urgent")
summary_contains: Filter todos where summary contains this text
Returns:
List of todos matching the filters
"""
client = get_client(ctx)
# Build filters dictionary
filters = {}
if status is not None:
filters["status"] = status
if min_priority is not None:
filters["min_priority"] = min_priority
if categories is not None:
filters["categories"] = [cat.strip() for cat in categories.split(",")]
if summary_contains is not None:
filters["summary_contains"] = summary_contains
todos_data = await client.calendar.list_todos(
calendar_name, filters if filters else None
)
todos = [Todo(**todo_data) for todo_data in todos_data]
return ListTodosResponse(
todos=todos, calendar_name=calendar_name, total_count=len(todos)
)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_create_todo(
calendar_name: str,
summary: str,
ctx: Context,
description: str = "",
status: str = "NEEDS-ACTION",
priority: int = 0,
due: str = "",
dtstart: str = "",
categories: str = "",
):
"""Create a new todo/task in a calendar.
Args:
calendar_name: Name of the calendar to create the todo in
summary: Todo title/summary
ctx: MCP context
description: Detailed description of the todo
status: Todo status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
priority: Priority (0=undefined, 1=highest, 9=lowest)
due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00")
dtstart: Start date/time (ISO format)
categories: Comma-separated categories (e.g., "work,urgent")
Returns:
Dict with todo creation result
"""
client = get_client(ctx)
todo_data = {
"summary": summary,
"description": description,
"status": status,
"priority": priority,
"due": due,
"dtstart": dtstart,
"categories": categories,
}
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_update_todo(
calendar_name: str,
todo_uid: str,
ctx: Context,
summary: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
priority: Optional[int] = None,
percent_complete: Optional[int] = None,
due: Optional[str] = None,
dtstart: Optional[str] = None,
completed: Optional[str] = None,
categories: Optional[str] = None,
):
"""Update an existing todo/task.
Args:
calendar_name: Name of the calendar containing the todo
todo_uid: UID of the todo to update
ctx: MCP context
summary: New summary/title
description: New description
status: New status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
priority: New priority (0-9)
percent_complete: New completion percentage (0-100)
due: New due date/time (ISO format)
dtstart: New start date/time (ISO format)
completed: Completion timestamp (ISO format)
categories: New categories (comma-separated)
Returns:
Dict with todo update result
"""
client = get_client(ctx)
# Build update data with only non-None values
todo_data = {}
if summary is not None:
todo_data["summary"] = summary
if description is not None:
todo_data["description"] = description
if status is not None:
todo_data["status"] = status
if priority is not None:
todo_data["priority"] = priority
if percent_complete is not None:
todo_data["percent_complete"] = percent_complete
if due is not None:
todo_data["due"] = due
if dtstart is not None:
todo_data["dtstart"] = dtstart
if completed is not None:
todo_data["completed"] = completed
if categories is not None:
todo_data["categories"] = categories
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
async def nc_calendar_delete_todo(
calendar_name: str,
todo_uid: str,
ctx: Context,
):
"""Delete a todo/task from a calendar.
Args:
calendar_name: Name of the calendar containing the todo
todo_uid: UID of the todo to delete
ctx: MCP context
Returns:
Dict with deletion status
"""
client = get_client(ctx)
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool()
@require_scopes("todo:read", "calendar:read")
async def nc_calendar_search_todos(
ctx: Context,
status: Optional[str] = None,
min_priority: Optional[int] = None,
categories: Optional[str] = None,
summary_contains: Optional[str] = None,
):
"""Search todos across all calendars with optional filtering.
Args:
ctx: MCP context
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
min_priority: Filter by minimum priority (1=highest, 9=lowest)
categories: Filter by categories (comma-separated, e.g., "work,urgent")
summary_contains: Filter todos where summary contains this text
Returns:
List of todos matching the filters from all calendars
"""
client = get_client(ctx)
# Build filters dictionary
filters = {}
if status is not None:
filters["status"] = status
if min_priority is not None:
filters["min_priority"] = min_priority
if categories is not None:
filters["categories"] = [cat.strip() for cat in categories.split(",")]
if summary_contains is not None:
filters["summary_contains"] = summary_contains
todos_data = await client.calendar.search_todos_across_calendars(
filters if filters else None
)
todos = [Todo(**todo_data) for todo_data in todos_data]
return ListTodosResponse(todos=todos, total_count=len(todos))
+8 -16
View File
@@ -2,8 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
@@ -11,21 +10,18 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool()
@require_scopes("contacts:read")
async def nc_contacts_list_addressbooks(ctx: Context):
"""List all addressbooks for the user."""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.list_addressbooks()
@mcp.tool()
@require_scopes("contacts:read")
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool()
@require_scopes("contacts:write")
async def nc_contacts_create_addressbook(
ctx: Context, *, name: str, display_name: str
):
@@ -35,20 +31,18 @@ def configure_contacts_tools(mcp: FastMCP):
name: The name of the addressbook.
display_name: The display name of the addressbook.
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.create_addressbook(
name=name, display_name=display_name
)
@mcp.tool()
@require_scopes("contacts:write")
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
"""Delete an addressbook."""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.delete_addressbook(name=name)
@mcp.tool()
@require_scopes("contacts:write")
async def nc_contacts_create_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
):
@@ -59,20 +53,18 @@ def configure_contacts_tools(mcp: FastMCP):
uid: The unique ID for the contact.
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.create_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data
)
@mcp.tool()
@require_scopes("contacts:write")
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact."""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool()
@require_scopes("contacts:write")
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
@@ -84,7 +76,7 @@ def configure_contacts_tools(mcp: FastMCP):
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
etag: Optional ETag for optimistic concurrency control.
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.contacts.update_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
)
-608
View File
@@ -1,608 +0,0 @@
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.cookbook import (
Category,
CookbookConfig,
CreateRecipeResponse,
DeleteRecipeResponse,
ImportRecipeResponse,
Keyword,
ListCategoriesResponse,
ListKeywordsResponse,
ListRecipesResponse,
Recipe,
RecipeStub,
ReindexResponse,
SearchRecipesResponse,
UpdateRecipeResponse,
Version,
)
logger = logging.getLogger(__name__)
def configure_cookbook_tools(mcp: FastMCP):
@mcp.resource("cookbook://version")
async def cookbook_get_version():
"""Get the Cookbook app and API version"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
version_data = await client.cookbook.get_version()
return Version(**version_data)
@mcp.resource("cookbook://config")
async def cookbook_get_config():
"""Get the Cookbook app configuration"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
config_data = await client.cookbook.get_config()
return CookbookConfig(**config_data)
@mcp.resource("nc://Cookbook/{recipe_id}")
async def nc_cookbook_get_recipe_resource(recipe_id: int):
"""Get a recipe by ID using resource URI"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
This extracts recipe data from websites that use schema.org Recipe markup.
Many popular recipe sites support this standard."""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.import_recipe(url)
recipe = Recipe(**recipe_data)
return ImportRecipeResponse(
recipe=recipe,
recipe_id=recipe.id or "unknown",
)
except RequestError as e:
# RequestError can have empty str() - get details from exception attributes
error_detail = (
str(e)
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
)
raise McpError(
ErrorData(
code=-1,
message=f"Network error importing recipe from {url}: {error_detail}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 400:
raise McpError(
ErrorData(
code=-1,
message=f"Invalid URL or missing 'url' field: {url}",
)
)
elif e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message="A recipe with this name already exists. Import aborted.",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to import recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.list_recipes()
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list recipes: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
try:
recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> CreateRecipeResponse:
"""Create a new recipe.
Required: name
Optional: All other recipe fields following schema.org/Recipe format.
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
client = get_client(ctx)
recipe_data = {"name": name}
if description:
recipe_data["description"] = description
if ingredients:
recipe_data["recipeIngredient"] = ingredients
if instructions:
recipe_data["recipeInstructions"] = instructions
if url:
recipe_data["url"] = url
if prep_time:
recipe_data["prepTime"] = prep_time
if cook_time:
recipe_data["cookTime"] = cook_time
if total_time:
recipe_data["totalTime"] = total_time
if recipe_yield:
recipe_data["recipeYield"] = recipe_yield
if category:
recipe_data["recipeCategory"] = category
if keywords:
recipe_data["keywords"] = keywords
try:
recipe_id = await client.cookbook.create_recipe(recipe_data)
return CreateRecipeResponse(id=recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 409:
raise McpError(
ErrorData(
code=-1,
message=f"A recipe with name '{name}' already exists",
)
)
elif e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to create recipes",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to create recipe: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
description: str | None = None,
ingredients: list[str] | None = None,
instructions: list[str] | None = None,
url: str | None = None,
prep_time: str | None = None,
cook_time: str | None = None,
total_time: str | None = None,
recipe_yield: int | None = None,
category: str | None = None,
keywords: str | None = None,
ctx: Context = None,
) -> UpdateRecipeResponse:
"""Update an existing recipe.
Provide only the fields you want to update. Unspecified fields remain unchanged."""
client = get_client(ctx)
# First get the current recipe
try:
current_recipe = await client.cookbook.get_recipe(recipe_id)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
)
)
# Update only specified fields
recipe_data = current_recipe.copy()
if name is not None:
recipe_data["name"] = name
if description is not None:
recipe_data["description"] = description
if ingredients is not None:
recipe_data["recipeIngredient"] = ingredients
if instructions is not None:
recipe_data["recipeInstructions"] = instructions
if url is not None:
recipe_data["url"] = url
if prep_time is not None:
recipe_data["prepTime"] = prep_time
if cook_time is not None:
recipe_data["cookTime"] = cook_time
if total_time is not None:
recipe_data["totalTime"] = total_time
if recipe_yield is not None:
recipe_data["recipeYield"] = recipe_yield
if category is not None:
recipe_data["recipeCategory"] = category
if keywords is not None:
recipe_data["keywords"] = keywords
try:
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
return UpdateRecipeResponse(id=updated_id)
except HTTPStatusError as e:
if e.response.status_code == 422:
raise McpError(
ErrorData(
code=-1,
message="Recipe name is required and cannot be empty",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
"""Delete a recipe permanently"""
logger.info("Deleting recipe %s", recipe_id)
client = get_client(ctx)
try:
message = await client.cookbook.delete_recipe(recipe_id)
return DeleteRecipeResponse(
status_code=200,
message=message,
deleted_id=recipe_id,
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
"""Search for recipes by keywords, tags, and categories"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.search_recipes(query)
recipes = [RecipeStub(**r) for r in recipes_data]
return SearchRecipesResponse(
recipes=recipes, query=query, total_found=len(recipes)
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to search recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Search failed: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Search failed: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
Note: A category name of '*' indicates recipes with no category."""
client = get_client(ctx)
try:
categories_data = await client.cookbook.list_categories()
categories = [Category(**c) for c in categories_data]
return ListCategoriesResponse(categories=categories)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list categories",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list categories: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
"""Get all recipes in a specific category.
Use '_' as the category name to get recipes with no category."""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_in_category(category)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message=f"Could not find category '{category}'",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
try:
keywords_data = await client.cookbook.list_keywords()
keywords = [Keyword(**k) for k in keywords_data]
return ListKeywordsResponse(keywords=keywords)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to list keywords",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:read")
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
"""Get all recipes that have specific keywords/tags"""
client = get_client(ctx)
try:
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
recipes = [RecipeStub(**r) for r in recipes_data]
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to access recipes",
)
)
elif e.response.status_code == 500:
raise McpError(
ErrorData(
code=-1,
message="Failed to get recipes with keywords: server error",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
print_image: bool | None = None,
ctx: Context = None,
) -> ReindexResponse:
"""Set Cookbook app configuration.
Args:
folder: Recipe folder path in user's files
update_interval: Automatic rescan interval in minutes
print_image: Whether to print images with recipes"""
client = get_client(ctx)
config_data = {}
if folder is not None:
config_data["folder"] = folder
if update_interval is not None:
config_data["update_interval"] = update_interval
if print_image is not None:
config_data["print_image"] = print_image
try:
result = await client.cookbook.set_config(config_data)
return ReindexResponse(status_code=200, message=str(result))
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to set configuration",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to set configuration: server error ({e.response.status_code})",
)
)
@mcp.tool()
@require_scopes("cookbook:write")
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
This rebuilds the search index and should be used after manual file changes."""
client = get_client(ctx)
try:
message = await client.cookbook.reindex()
return ReindexResponse(status_code=200, message=message)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message="Access denied: insufficient permissions to reindex",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to reindex: server error ({e.response.status_code})",
)
)
+31 -147
View File
@@ -3,20 +3,15 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import (
CardOperationResponse,
CreateBoardResponse,
CreateCardResponse,
CreateLabelResponse,
CreateStackResponse,
DeckBoard,
DeckCard,
DeckLabel,
DeckStack,
LabelOperationResponse,
StackOperationResponse,
CreateCardResponse,
CardOperationResponse,
CreateLabelResponse,
LabelOperationResponse,
)
logger = logging.getLogger(__name__)
@@ -30,8 +25,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_boards_resource():
"""List all Nextcloud Deck boards"""
ctx: Context = mcp.get_context()
await ctx.warning("This message is deprecated, use the deck_get_board instead")
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
boards = await client.deck.get_boards()
return [board.model_dump() for board in boards]
@@ -39,10 +33,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_board_resource(board_id: int):
"""Get details of a specific Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_board tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return board.model_dump()
@@ -50,10 +41,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_stacks_resource(board_id: int):
"""List all stacks in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_stacks tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stacks = await client.deck.get_stacks(board_id)
return [stack.model_dump() for stack in stacks]
@@ -61,10 +49,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_stack_resource(board_id: int, stack_id: int):
"""Get details of a specific Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_stack tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
return stack.model_dump()
@@ -72,10 +57,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_cards_resource(board_id: int, stack_id: int):
"""List all cards in a Nextcloud Deck stack"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_cards tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return [card.model_dump() for card in stack.cards]
@@ -85,10 +67,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_card_resource(board_id: int, stack_id: int, card_id: int):
"""Get details of a specific Nextcloud Deck card"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_card tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.get_card(board_id, stack_id, card_id)
return card.model_dump()
@@ -96,10 +75,7 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_labels_resource(board_id: int):
"""List all labels in a Nextcloud Deck board"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_labels tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels]
@@ -107,89 +83,13 @@ def configure_deck_tools(mcp: FastMCP):
async def deck_label_resource(board_id: int, label_id: int):
"""Get details of a specific Nextcloud Deck label"""
ctx: Context = mcp.get_context()
await ctx.warning(
"This resource is deprecated, use the deck_get_label tool instead"
)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.get_label(board_id, label_id)
return label.model_dump()
# Read Tools (converted from resources)
# Tools
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client = get_client(ctx)
boards = await client.deck.get_boards()
return boards
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client = get_client(ctx)
board = await client.deck.get_board(board_id)
return board
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx)
stacks = await client.deck.get_stacks(board_id)
return stacks
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
client = get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
return stack
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
"""Get all cards in a Nextcloud Deck stack"""
client = get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards:
return stack.cards
return []
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
"""Get details of a specific Nextcloud Deck card"""
client = get_client(ctx)
card = await client.deck.get_card(board_id, stack_id, card_id)
return card
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client = get_client(ctx)
board = await client.deck.get_board(board_id)
return board.labels
@mcp.tool()
@require_scopes("deck:read")
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
client = get_client(ctx)
label = await client.deck.get_label(board_id, label_id)
return label
# Create/Update/Delete Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_create_board(
ctx: Context, title: str, color: str
) -> CreateBoardResponse:
@@ -199,14 +99,13 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new board
color: The hexadecimal color of the new board (e.g. FF0000)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
board = await client.deck.create_board(title, color)
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
# Stack Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
) -> CreateStackResponse:
@@ -217,12 +116,11 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new stack
order: Order for sorting the stacks
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
stack = await client.deck.create_stack(board_id, title, order)
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
@require_scopes("deck:write")
async def deck_update_stack(
ctx: Context,
board_id: int,
@@ -238,7 +136,7 @@ def configure_deck_tools(mcp: FastMCP):
title: New title for the stack
order: New order for the stack
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_stack(board_id, stack_id, title, order)
return StackOperationResponse(
success=True,
@@ -248,7 +146,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
) -> StackOperationResponse:
@@ -258,7 +155,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id: The ID of the board
stack_id: The ID of the stack
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_stack(board_id, stack_id)
return StackOperationResponse(
success=True,
@@ -269,7 +166,6 @@ def configure_deck_tools(mcp: FastMCP):
# Card Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_create_card(
ctx: Context,
board_id: int,
@@ -291,7 +187,7 @@ def configure_deck_tools(mcp: FastMCP):
description: Description of the card
duedate: Due date of the card (ISO-8601 format)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
card = await client.deck.create_card(
board_id, stack_id, title, type, order, description, duedate
)
@@ -303,7 +199,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_update_card(
ctx: Context,
board_id: int,
@@ -333,7 +228,7 @@ def configure_deck_tools(mcp: FastMCP):
archived: Whether the card should be archived
done: Completion date for the card (ISO-8601 format)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_card(
board_id,
stack_id,
@@ -356,7 +251,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -367,7 +261,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack
card_id: The ID of the card
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
@@ -378,7 +272,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -389,7 +282,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack
card_id: The ID of the card
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.archive_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
@@ -400,7 +293,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -411,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack
card_id: The ID of the card
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.unarchive_card(board_id, stack_id, card_id)
return CardOperationResponse(
success=True,
@@ -422,7 +314,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_reorder_card(
ctx: Context,
board_id: int,
@@ -440,7 +331,7 @@ def configure_deck_tools(mcp: FastMCP):
order: New position in the target stack
target_stack_id: The ID of the target stack
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.reorder_card(
board_id, stack_id, card_id, order, target_stack_id
)
@@ -454,7 +345,6 @@ def configure_deck_tools(mcp: FastMCP):
# Label Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
) -> CreateLabelResponse:
@@ -465,12 +355,11 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new label
color: The color of the new label (hex format without #)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
label = await client.deck.create_label(board_id, title, color)
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
@require_scopes("deck:write")
async def deck_update_label(
ctx: Context,
board_id: int,
@@ -486,7 +375,7 @@ def configure_deck_tools(mcp: FastMCP):
title: New title for the label
color: New color for the label (hex format without #)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.update_label(board_id, label_id, title, color)
return LabelOperationResponse(
success=True,
@@ -496,7 +385,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
) -> LabelOperationResponse:
@@ -506,7 +394,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id: The ID of the board
label_id: The ID of the label
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.delete_label(board_id, label_id)
return LabelOperationResponse(
success=True,
@@ -517,7 +405,6 @@ def configure_deck_tools(mcp: FastMCP):
# Card-Label Assignment Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -529,7 +416,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card
label_id: The ID of the label to assign
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse(
success=True,
@@ -540,7 +427,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -552,7 +438,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card
label_id: The ID of the label to remove
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse(
success=True,
@@ -564,7 +450,6 @@ def configure_deck_tools(mcp: FastMCP):
# Card-User Assignment Tools
@mcp.tool()
@require_scopes("deck:write")
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
@@ -576,7 +461,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card
user_id: The user ID to assign
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse(
success=True,
@@ -587,7 +472,6 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("deck:write")
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
@@ -599,7 +483,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card
user_id: The user ID to unassign
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse(
success=True,
+23 -138
View File
@@ -1,21 +1,20 @@
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from httpx import HTTPStatusError
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.notes import (
AppendContentResponse,
CreateNoteResponse,
DeleteNoteResponse,
Note,
NoteSearchResult,
NotesSettings,
SearchNotesResponse,
CreateNoteResponse,
UpdateNoteResponse,
DeleteNoteResponse,
AppendContentResponse,
SearchNotesResponse,
NoteSearchResult,
)
logger = logging.getLogger(__name__)
@@ -28,15 +27,15 @@ def configure_notes_tools(mcp: FastMCP):
ctx: Context = (
mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
settings_data = await client.notes.get_settings()
return NotesSettings(**settings_data)
@mcp.resource("nc://Notes/{note_id}/attachments/{attachment_filename}")
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
async def nc_notes_get_attachment(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Assuming a method get_note_attachment exists in the client
# This method should return the raw content and determine the mime type
content, mime_type = await client.webdav.get_note_attachment(
@@ -54,21 +53,14 @@ def configure_notes_tools(mcp: FastMCP):
}
@mcp.resource("nc://Notes/{note_id}")
async def nc_get_note_resource(note_id: int):
async def nc_get_note(note_id: int):
"""Get user note using note id"""
ctx: Context = mcp.get_context()
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error retrieving note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -85,12 +77,11 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("notes:write")
async def nc_notes_create_note(
title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse:
"""Create a new note (requires notes:write scope)"""
client = get_client(ctx)
"""Create a new note"""
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
note_data = await client.notes.create_note(
title=title,
@@ -101,10 +92,6 @@ def configure_notes_tools(mcp: FastMCP):
return CreateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
@@ -131,7 +118,6 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("notes:write")
async def nc_notes_update_note(
note_id: int,
etag: str,
@@ -140,14 +126,14 @@ def configure_notes_tools(mcp: FastMCP):
category: str | None,
ctx: Context,
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category (requires notes:write scope).
"""Update an existing note's title, content, or category.
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
Get the current ETag by first retrieving the note using nc://Notes/{note_id} resource.
If the note has been modified by someone else since you retrieved it,
the update will fail with a 412 error."""
logger.info("Updating note %s", note_id)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
note_data = await client.notes.update(
note_id=note_id,
@@ -160,12 +146,6 @@ def configure_notes_tools(mcp: FastMCP):
return UpdateNoteResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -196,7 +176,6 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("notes:write")
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
@@ -204,7 +183,7 @@ def configure_notes_tools(mcp: FastMCP):
between the note and what will be appended."""
logger.info("Appending content to note %s", note_id)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
note_data = await client.notes.append_content(
note_id=note_id, content=content
@@ -213,13 +192,6 @@ def configure_notes_tools(mcp: FastMCP):
return AppendContentResponse(
id=note.id, title=note.title, category=note.category, etag=note.etag
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error appending to note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
@@ -246,10 +218,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
client = get_client(ctx)
"""Search notes by title or content, returning only id, title, and category."""
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -267,10 +238,6 @@ def configure_notes_tools(mcp: FastMCP):
return SearchNotesResponse(
results=results, query=query, total_found=len(results)
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
)
except HTTPStatusError as e:
if e.response.status_code == 403:
raise McpError(
@@ -292,86 +259,10 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID (requires notes:read scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
return Note(**note_data)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
elif e.response.status_code == 403:
raise McpError(
ErrorData(code=-1, message=f"Access denied to note {note_id}")
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve note {note_id}: {e.response.reason_phrase}",
)
)
@mcp.tool()
@require_scopes("notes:read")
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
"""Get a specific attachment from a note"""
client = get_client(ctx)
try:
content, mime_type = await client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename
)
return {
"uri": f"nc://Notes/{note_id}/attachments/{attachment_filename}",
"mimeType": mime_type,
"data": content,
}
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(
ErrorData(
code=-1,
message=f"Attachment {attachment_filename} not found for note {note_id}",
)
)
elif e.response.status_code == 403:
raise McpError(
ErrorData(
code=-1,
message=f"Access denied to attachment {attachment_filename} for note {note_id}",
)
)
else:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to retrieve attachment: {e.response.reason_phrase}",
)
)
@mcp.tool()
@require_scopes("notes:write")
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
try:
await client.notes.delete_note(note_id)
return DeleteNoteResponse(
@@ -379,12 +270,6 @@ def configure_notes_tools(mcp: FastMCP):
message=f"Note {note_id} deleted successfully",
deleted_id=note_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
-140
View File
@@ -1,140 +0,0 @@
"""MCP tools for Nextcloud file/folder sharing operations."""
import json
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
def configure_sharing_tools(mcp: FastMCP):
"""Configure sharing-related MCP tools.
Args:
mcp: FastMCP server instance
"""
@mcp.tool()
@require_scopes("sharing:write")
async def nc_share_create(
path: str,
share_with: str,
ctx: Context,
share_type: int = 0,
permissions: int = 1,
) -> str:
"""Create a share for a file or folder in Nextcloud.
Share a file or folder with another user or group. The authenticated user
must own the file/folder being shared.
Args:
path: Path to file/folder to share (relative to your files, e.g., "/document.txt")
share_with: Username (for user share) or group name (for group share)
share_type: Share type - 0 for user (default), 1 for group, 3 for public link
permissions: Share permissions (default: 1 for read-only):
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with share information including share ID
"""
client = get_client(ctx)
share_data = await client.sharing.create_share(
path=path,
share_with=share_with,
share_type=share_type,
permissions=permissions,
)
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("sharing:write")
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
Remove a share that you created. You must be the owner of the share.
Args:
share_id: The ID of the share to delete
Returns:
JSON string confirming deletion
"""
client = get_client(ctx)
await client.sharing.delete_share(share_id)
return json.dumps(
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
)
@mcp.tool()
@require_scopes("sharing:write")
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
Retrieve details about a share by its ID. You must have access to the share
(either as owner or recipient).
Args:
share_id: The ID of the share
Returns:
JSON string with share information
"""
client = get_client(ctx)
share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("sharing:write")
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
) -> str:
"""List shares created by you or shared with you.
Args:
path: Optional path to filter shares for a specific file/folder
shared_with_me: If True, list shares that others shared with you.
If False (default), list shares you created.
Returns:
JSON string with list of shares
"""
client = get_client(ctx)
shares = await client.sharing.list_shares(
path=path, shared_with_me=shared_with_me
)
return json.dumps(shares, indent=2)
@mcp.tool()
@require_scopes("sharing:write")
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
Modify the permissions for a share you created. You must be the owner.
Args:
share_id: The ID of the share to update
permissions: New permissions value:
- 1 = read
- 2 = update
- 4 = create
- 8 = delete
- 16 = share
- 31 = all permissions
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
Returns:
JSON string with updated share information
"""
client = get_client(ctx)
share_data = await client.sharing.update_share(
share_id=share_id, permissions=permissions
)
return json.dumps(share_data, indent=2)
+7 -14
View File
@@ -2,8 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
@@ -11,21 +10,18 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool()
@require_scopes("tables:read")
async def nc_tables_list_tables(ctx: Context):
"""List all tables available to the user"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.list_tables()
@mcp.tool()
@require_scopes("tables:read")
async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.get_table_schema(table_id)
@mcp.tool()
@require_scopes("tables:read")
async def nc_tables_read_table(
table_id: int,
ctx: Context,
@@ -33,32 +29,29 @@ def configure_tables_tools(mcp: FastMCP):
offset: int | None = None,
):
"""Read rows from a table with optional pagination"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool()
@require_scopes("tables:write")
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
"""Insert a new row into a table.
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.create_row(table_id, data)
@mcp.tool()
@require_scopes("tables:write")
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
"""Update an existing row in a table.
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.update_row(row_id, data)
@mcp.tool()
@require_scopes("tables:write")
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.tables.delete_row(row_id)
+74 -233
View File
@@ -2,9 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
@@ -12,40 +10,26 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_list_directory(
ctx: Context, path: str = ""
) -> DirectoryListing:
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
"""List files and directories in the specified NextCloud path.
Args:
path: Directory path to list (empty string for root directory)
Returns:
DirectoryListing with files, total_count, directories_count, files_count, and total_size
List of items with metadata including name, path, is_directory, size, content_type, last_modified
Examples:
# List root directory
await nc_webdav_list_directory("")
# List a specific folder
await nc_webdav_list_directory("Documents/Projects")
"""
client = get_client(ctx)
items = await client.webdav.list_directory(path)
# Convert to FileInfo models
file_infos = [FileInfo(**item) for item in items]
# Calculate metadata
directories_count = sum(1 for f in file_infos if f.is_directory)
files_count = sum(1 for f in file_infos if not f.is_directory)
total_size = sum(f.size or 0 for f in file_infos if not f.is_directory)
return DirectoryListing(
path=path,
files=file_infos,
total_count=len(file_infos),
directories_count=directories_count,
files_count=files_count,
total_size=total_size,
)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.list_directory(path)
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -55,8 +39,17 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with path, content, content_type, size, and encoding (if binary)
Text files are decoded to UTF-8, binary files are base64 encoded
Examples:
# Read a text file
result = await nc_webdav_read_file("Documents/readme.txt")
logger.info(result['content']) # Decoded text content
# Read a binary file
result = await nc_webdav_read_file("Images/photo.jpg")
logger.info(result['encoding']) # 'base64'
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
content, content_type = await client.webdav.read_file(path)
# For text files, decode content for easier viewing
@@ -84,7 +77,6 @@ def configure_webdav_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("files:write")
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
):
@@ -97,8 +89,15 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating success
Examples:
# Write a text file
await nc_webdav_write_file("Documents/notes.md", "# My Notes\nContent here...")
# Write binary data (base64 encoded)
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
# Handle base64 encoded content
if content_type and "base64" in content_type.lower():
@@ -112,7 +111,6 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@require_scopes("files:write")
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -121,12 +119,18 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code (201 for created, 405 if already exists)
Examples:
# Create a single directory
await nc_webdav_create_directory("NewProject")
# Create nested directories (parent must exist)
await nc_webdav_create_directory("Projects/MyApp/docs")
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.create_directory(path)
@mcp.tool()
@require_scopes("files:write")
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -135,12 +139,18 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if not found)
Examples:
# Delete a file
await nc_webdav_delete_resource("old_document.txt")
# Delete a directory (will delete all contents)
await nc_webdav_delete_resource("temp_folder")
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.delete_resource(path)
@mcp.tool()
@require_scopes("files:write")
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -153,14 +163,26 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# Rename a file
await nc_webdav_move_resource("document.txt", "new_name.txt")
# Move a file to another directory
await nc_webdav_move_resource("document.txt", "Archive/document.txt")
# Move a directory
await nc_webdav_move_resource("Projects/OldProject", "Projects/NewProject")
# Move and overwrite if destination exists
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.move_resource(
source_path, destination_path, overwrite
)
@mcp.tool()
@require_scopes("files:write")
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -173,202 +195,21 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
Examples:
# Copy a file
await nc_webdav_copy_resource("document.txt", "document_copy.txt")
# Copy a file to another directory
await nc_webdav_copy_resource("document.txt", "Backup/document.txt")
# Copy a directory
await nc_webdav_copy_resource("Projects/ProjectA", "Projects/ProjectA_Backup")
# Copy and overwrite if destination exists
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
"""
client = get_client(ctx)
client: NextcloudClient = ctx.request_context.lifespan_context.client
return await client.webdav.copy_resource(
source_path, destination_path, overwrite
)
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_search_files(
ctx: Context,
scope: str = "",
name_pattern: str | None = None,
mime_type: str | None = None,
only_favorites: bool = False,
limit: int | None = None,
) -> SearchFilesResponse:
"""Search for files in NextCloud using WebDAV SEARCH.
This is a high-level search tool that supports common search patterns.
For more complex queries, use the specific search tools.
Args:
scope: Directory path to search in (empty string for user root)
name_pattern: File name pattern (supports % wildcard, e.g., "%.txt" for all text files)
mime_type: MIME type to filter by (supports % wildcard, e.g., "image/%" for all images)
only_favorites: If True, only return favorited files
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
# Build where conditions based on filters
conditions = []
if name_pattern:
conditions.append(
f"""
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>{name_pattern}</d:literal>
</d:like>
"""
)
if mime_type:
conditions.append(
f"""
<d:like>
<d:prop>
<d:getcontenttype/>
</d:prop>
<d:literal>{mime_type}</d:literal>
</d:like>
"""
)
if only_favorites:
conditions.append(
"""
<d:eq>
<d:prop>
<oc:favorite/>
</d:prop>
<d:literal>1</d:literal>
</d:eq>
"""
)
# Combine conditions with AND if multiple
if len(conditions) > 1:
where_conditions = f"""
<d:and>
{"".join(conditions)}
</d:and>
"""
elif len(conditions) == 1:
where_conditions = conditions[0]
else:
where_conditions = None
# Include extended properties
properties = [
"displayname",
"getcontentlength",
"getcontenttype",
"getlastmodified",
"resourcetype",
"getetag",
"fileid",
"favorite",
]
results = await client.webdav.search_files(
scope=scope,
where_conditions=where_conditions,
properties=properties,
limit=limit,
)
# Convert to FileInfo models
file_infos = [FileInfo(**result) for result in results]
# Build filters applied dict
filters = {}
if name_pattern:
filters["name_pattern"] = name_pattern
if mime_type:
filters["mime_type"] = mime_type
if only_favorites:
filters["only_favorites"] = True
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied=filters if filters else None,
)
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_find_by_name(
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""Find files by name pattern in NextCloud.
Args:
pattern: Name pattern to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
results = await client.webdav.find_by_name(
pattern=pattern, scope=scope, limit=limit
)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"name_pattern": pattern},
)
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_find_by_type(
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""Find files by MIME type in NextCloud.
Args:
mime_type: MIME type to search for (supports % wildcard)
scope: Directory path to search in (empty string for user root)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
results = await client.webdav.find_by_type(
mime_type=mime_type, scope=scope, limit=limit
)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"mime_type": mime_type},
)
@mcp.tool()
@require_scopes("files:read")
async def nc_webdav_list_favorites(
ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
"""List all favorite files in NextCloud.
Args:
scope: Directory path to search in (empty string for all favorites)
limit: Maximum number of results to return
Returns:
SearchFilesResponse with list of favorite files
"""
client = get_client(ctx)
results = await client.webdav.list_favorites(scope=scope, limit=limit)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(
results=file_infos,
total_found=len(file_infos),
scope=scope,
filters_applied={"only_favorites": True},
)
+14 -63
View File
@@ -1,62 +1,32 @@
[project]
name = "nextcloud-mcp-server"
version = "0.18.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
version = "0.12.1"
description = ""
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
{name = "Chris Coutinho",email = "chris@coutinho.io"}
]
readme = "README.md"
license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.18,<1.19)",
"mcp[cli] (>=1.13,<1.14)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=12.0.0,<12.1.0)",
"pillow (>=11.2.1,<12.0.0)",
"icalendar (>=6.0.0,<7.0.0)",
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Communications",
"Topic :: Internet :: WWW/HTTP",
]
[project.urls]
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
[tool.pytest.ini_options]
anyio_mode = "auto"
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session"
log_cli = 1
log_cli_level = "ERROR"
log_level = "ERROR"
log_cli_level = "INFO"
log_level = "INFO"
markers = [
"unit: Fast unit tests with mocked dependencies",
"integration: Integration tests requiring Docker containers",
"oauth: OAuth tests requiring Playwright (slowest)",
"smoke: Critical path smoke tests for quick validation",
"integration: marks tests as slow (deselect with '-m \"not slow\"')"
]
testpaths = [
"tests",
]
# Timeout settings to prevent tests from hanging indefinitely
timeout = 180 # 3 minutes default timeout per test (includes fixture setup)
timeout_func_only = false # Timeout includes fixture setup/teardown
[tool.commitizen]
name = "cz_conventional_commits"
@@ -66,38 +36,19 @@ version_provider = "uv"
update_changelog_on_bump = true
major_version_zero = true
[tool.ruff.lint]
extend-select = ["I"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
[build-system]
requires = ["uv_build>=0.9.4,<0.10.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "nextcloud_mcp_server"
module-root = ""
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"commitizen>=4.8.2",
"ipython>=9.2.0",
"playwright>=1.49.1",
"pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-mock>=3.15.1",
"pytest-playwright-asyncio>=0.7.1",
"pytest-timeout>=2.3.1",
"ruff>=0.11.13",
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
-307
View File
@@ -1,307 +0,0 @@
#!/usr/bin/env python3
"""Script to automatically add @require_scopes decorators to MCP tools.
This script parses server module files and adds appropriate scope decorators
based on the operation type (read vs write).
Usage:
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
"""
import argparse
import ast
import re
from pathlib import Path
from typing import List, Tuple
# Operation patterns for classification
READ_PATTERNS = [
r".*_get_.*",
r".*_get$",
r".*_list_.*",
r".*_list$",
r".*_search_.*",
r".*_search$",
r".*_read_.*",
r".*_read$",
r".*_find_.*",
r".*_find$",
r".*_fetch_.*",
r".*_fetch$",
r".*_retrieve_.*",
r".*_retrieve$",
]
WRITE_PATTERNS = [
r".*_create_.*",
r".*_create$",
r".*_update_.*",
r".*_update$",
r".*_delete_.*",
r".*_delete$",
r".*_append_.*",
r".*_append$",
r".*_modify_.*",
r".*_modify$",
r".*_set_.*",
r".*_set$",
r".*_add_.*",
r".*_add$",
r".*_remove_.*",
r".*_remove$",
r".*_edit_.*",
r".*_edit$",
r".*_move_.*",
r".*_move$",
r".*_copy_.*",
r".*_copy$",
r".*_upload_.*",
r".*_upload$",
r".*_download_.*",
r".*_download$",
r".*_share_.*",
r".*_share$",
r".*_unshare_.*",
r".*_unshare$",
r".*_bulk_.*", # Bulk operations are typically writes
]
def classify_operation(func_name: str) -> str | None:
"""Classify a function as read or write operation.
Args:
func_name: Function name to classify
Returns:
"nc:read", "nc:write", or None if cannot classify
"""
# Check write patterns first (more specific)
for pattern in WRITE_PATTERNS:
if re.match(pattern, func_name):
return "nc:write"
# Check read patterns
for pattern in READ_PATTERNS:
if re.match(pattern, func_name):
return "nc:read"
return None
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function already has @require_scopes decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if (
isinstance(decorator.func, ast.Name)
and decorator.func.id == "require_scopes"
):
return True
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
return True
return False
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function has @mcp.tool() decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if isinstance(decorator.func, ast.Attribute):
if decorator.func.attr == "tool":
return True
return False
def find_tools_needing_decorators(
file_path: Path, verbose: bool = False
) -> List[Tuple[str, int, str]]:
"""Find all tools that need scope decorators.
Returns:
List of (function_name, line_number, required_scope)
"""
with open(file_path) as f:
content = f.read()
try:
tree = ast.parse(content)
except SyntaxError as e:
print(f" ⚠️ Syntax error in {file_path}: {e}")
return []
tools_to_update = []
total_functions = 0
mcp_tools = 0
already_has_scope = 0
cannot_classify = 0
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
total_functions += 1
if verbose and node.decorator_list:
decorators_str = [
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
for d in node.decorator_list
]
print(f" Function {node.name} has decorators: {decorators_str}")
# Check if it's an MCP tool
if not has_mcp_tool_decorator(node.decorator_list):
continue
mcp_tools += 1
# Check if it already has scope decorator
if has_scope_decorator(node.decorator_list):
already_has_scope += 1
continue
# Classify operation
scope = classify_operation(node.name)
if scope:
tools_to_update.append((node.name, node.lineno, scope))
else:
cannot_classify += 1
if verbose:
print(f" ⚠️ Cannot classify: {node.name}")
if verbose:
print(
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
)
return tools_to_update
def add_decorator_to_file(
file_path: Path, dry_run: bool = False, verbose: bool = False
) -> int:
"""Add @require_scopes decorators to tools in a file.
Returns:
Number of decorators added
"""
tools = find_tools_needing_decorators(file_path, verbose=verbose)
if not tools:
return 0
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
has_import = True
break
elif "from nextcloud_mcp_server.auth import" in line:
import_line_idx = i
# Add import if needed
if not has_import:
if import_line_idx is not None:
# Add require_scopes to existing import
old_line = lines[import_line_idx]
if "(" in old_line:
# Multi-line import
print(
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
)
else:
# Single line import - add require_scopes
lines[import_line_idx] = (
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
)
print(" ✓ Added require_scopes to import")
else:
# No auth import exists, add new import
# Find first import line
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
lines.insert(
i, "from nextcloud_mcp_server.auth import require_scopes\n"
)
print(
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
)
break
# Add decorators to tools (in reverse order to preserve line numbers)
for func_name, line_num, scope in reversed(tools):
# Find the @mcp.tool() decorator line
for i in range(line_num - 1, max(0, line_num - 10), -1):
if "@mcp.tool()" in lines[i]:
# Get indentation from @mcp.tool() line
indent = len(lines[i]) - len(lines[i].lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
lines.insert(i + 1, decorator_line)
print(f'{func_name}:{line_num} → @require_scopes("{scope}")')
break
if not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(" 💾 Saved changes")
else:
print(" 🔍 DRY RUN - no changes written")
return len(tools)
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Show debug information",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
added = add_decorator_to_file(
file_path, dry_run=args.dry_run, verbose=args.verbose
)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""Simpler script to add @require_scopes decorators using regex.
This script uses regex patterns to find @mcp.tool() decorators and adds
the appropriate @require_scopes decorator based on function name patterns.
Usage:
python scripts/add_scope_decorators_simple.py [--dry-run]
"""
import argparse
import re
from pathlib import Path
# Operation patterns for classification
READ_KEYWORDS = [
"get",
"list",
"search",
"read",
"find",
"fetch",
"retrieve",
"upcoming",
]
WRITE_KEYWORDS = [
"create",
"update",
"delete",
"append",
"modify",
"set",
"add",
"remove",
"edit",
"move",
"copy",
"upload",
"download",
"share",
"unshare",
"bulk",
"manage",
"import",
"reindex",
"archive",
"unarchive",
"reorder",
"assign",
"unassign",
"insert",
"write",
]
def classify_function(func_name: str) -> str | None:
"""Classify a function name as read or write operation."""
func_lower = func_name.lower()
# Check write keywords first (more specific)
for keyword in WRITE_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:write"
# Check read keywords
for keyword in READ_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:read"
return None
def process_file(file_path: Path, dry_run: bool = False) -> int:
"""Process a single file to add @require_scopes decorators.
Returns:
Number of decorators added
"""
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line:
if "require_scopes" in line:
has_import = True
else:
import_line_idx = i
modified = False
decorators_added = 0
# Find all @mcp.tool() decorators
i = 0
while i < len(lines):
line = lines[i]
# Look for @mcp.tool() decorator
if re.match(r"\s*@mcp\.tool\(\)", line):
# Check if next line already has @require_scopes
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
i += 1
continue
# Find the function definition (should be on next line or after other decorators)
func_line_idx = i + 1
while func_line_idx < len(lines) and not lines[
func_line_idx
].strip().startswith("async def"):
func_line_idx += 1
if func_line_idx >= len(lines):
i += 1
continue
# Extract function name
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
if not func_match:
i += 1
continue
func_name = func_match.group(1)
scope = classify_function(func_name)
if scope:
# Get indentation from @mcp.tool() line
indent = len(line) - len(line.lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
# Insert after @mcp.tool()
lines.insert(i + 1, decorator_line)
decorators_added += 1
modified = True
print(f'{func_name} → @require_scopes("{scope}")')
else:
print(f" ⚠️ Cannot classify: {func_name}")
i += 1
# Add import if needed and decorators were added
if decorators_added > 0 and not has_import:
if import_line_idx is not None:
# Add to existing import
old_line = lines[import_line_idx]
if old_line.rstrip().endswith(")"):
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
else:
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
print(" ✓ Added require_scopes to existing import")
modified = True
else:
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
last_nc_import_idx = None
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
last_nc_import_idx = i
if last_nc_import_idx is not None:
lines.insert(
last_nc_import_idx + 1,
"from nextcloud_mcp_server.auth import require_scopes\n",
)
print(
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
)
modified = True
else:
print(" ⚠️ Could not find place to add require_scopes import")
# Write changes
if modified and not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(f" 💾 Saved changes to {file_path.name}")
elif dry_run and decorators_added > 0:
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
return decorators_added
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
file_path = file_path.resolve() # Convert to absolute path
try:
display_path = file_path.relative_to(Path.cwd())
except ValueError:
display_path = file_path.name
print(f"\n📝 {display_path}")
added = process_file(file_path, dry_run=args.dry_run)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run and total_added > 0:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
View File
View File
-11
View File
@@ -1,11 +0,0 @@
"""Shared fixtures for calendar integration tests.
Note: The temporary_calendar fixture is defined in tests/conftest.py and uses
a shared session-scoped calendar to avoid Nextcloud rate limiting issues.
This conftest.py exists for any calendar-specific fixtures that might be needed
in the future.
"""
import logging
logger = logging.getLogger(__name__)
@@ -1,498 +0,0 @@
"""Integration tests for Calendar VTODO (task) operations."""
import logging
import uuid
from datetime import datetime, timedelta
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
@pytest.fixture
async def temporary_todo(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary todo for testing and clean up afterward."""
todo_uid = None
calendar_name = temporary_calendar
# Create a test todo
tomorrow = datetime.now() + timedelta(days=1)
todo_data = {
"summary": f"Test Task {uuid.uuid4().hex[:8]}",
"description": "Test todo created by integration tests",
"status": "NEEDS-ACTION",
"priority": 5,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing",
}
try:
logger.info(f"Creating temporary todo in calendar: {calendar_name}")
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result.get("uid")
if not todo_uid:
pytest.fail("Failed to create temporary todo")
logger.info(f"Created temporary todo with UID: {todo_uid}")
yield {"uid": todo_uid, "calendar_name": calendar_name, "data": todo_data}
finally:
# Cleanup
if todo_uid:
try:
logger.info(f"Cleaning up temporary todo: {todo_uid}")
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
logger.info(f"Successfully deleted temporary todo: {todo_uid}")
except HTTPStatusError as e:
if e.response.status_code != 404:
logger.error(f"Error deleting temporary todo {todo_uid}: {e}")
except Exception as e:
logger.error(
f"Unexpected error deleting temporary todo {todo_uid}: {e}"
)
# ============= Basic CRUD Tests =============
async def test_create_and_delete_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and deleting a basic todo."""
calendar_name = temporary_calendar
# Create todo
tomorrow = datetime.now() + timedelta(days=1)
todo_data = {
"summary": "Integration Test Task",
"description": "Test task for integration testing",
"status": "NEEDS-ACTION",
"priority": 3,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing,integration",
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
assert "uid" in result
assert result["status_code"] in [200, 201, 204]
todo_uid = result["uid"]
logger.info(f"Created todo with UID: {todo_uid}")
# Verify todo was created by listing todos
todos = await nc_client.calendar.list_todos(calendar_name)
todo_uids = [todo.get("uid") for todo in todos]
assert todo_uid in todo_uids
# Find our todo in the list
our_todo = next((t for t in todos if t.get("uid") == todo_uid), None)
assert our_todo is not None
assert our_todo["summary"] == "Integration Test Task"
assert our_todo["status"] == "NEEDS-ACTION"
assert our_todo["priority"] == 3
# Delete todo
delete_result = await nc_client.calendar.delete_todo(calendar_name, todo_uid)
assert delete_result["status_code"] in [200, 204, 404]
logger.info(f"Successfully deleted todo: {todo_uid}")
except Exception as e:
logger.error(f"Test failed: {e}")
raise
async def test_list_todos(nc_client: NextcloudClient, temporary_calendar: str):
"""Test listing todos in a calendar."""
calendar_name = temporary_calendar
# Create multiple todos
todo_uids = []
for i in range(3):
todo_data = {
"summary": f"Test Task {i + 1}",
"description": f"Task number {i + 1}",
"status": "NEEDS-ACTION",
"priority": i + 1,
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uids.append(result["uid"])
try:
# List todos
todos = await nc_client.calendar.list_todos(calendar_name)
assert isinstance(todos, list)
assert len(todos) >= 3 # At least our 3 todos
# Check structure
for todo in todos:
assert "uid" in todo
assert "summary" in todo
assert "status" in todo
assert "priority" in todo
# Verify our todos are in the list
listed_uids = [todo["uid"] for todo in todos]
for uid in todo_uids:
assert uid in listed_uids
logger.info(f"Found {len(todos)} todos in calendar")
finally:
# Cleanup
for uid in todo_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_update_todo(nc_client: NextcloudClient, temporary_todo: dict):
"""Test updating an existing todo."""
calendar_name = temporary_todo["calendar_name"]
todo_uid = temporary_todo["uid"]
# Update todo data
updated_data = {
"summary": "Updated Test Task Title",
"description": "Updated description for test task",
"status": "IN-PROCESS",
"priority": 1, # High priority
"percent_complete": 50,
}
try:
result = await nc_client.calendar.update_todo(
calendar_name, todo_uid, updated_data
)
assert result["uid"] == todo_uid
# Verify updates by listing todos
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert updated_todo is not None
assert updated_todo["summary"] == "Updated Test Task Title"
assert updated_todo["description"] == "Updated description for test task"
assert updated_todo["status"] == "IN-PROCESS"
assert updated_todo["priority"] == 1
assert updated_todo["percent_complete"] == 50
logger.info(f"Successfully updated todo: {todo_uid}")
except Exception as e:
logger.error(f"Todo update test failed: {e}")
raise
async def test_todo_with_dates(nc_client: NextcloudClient, temporary_calendar: str):
"""Test creating a todo with start, due, and completed dates."""
calendar_name = temporary_calendar
now = datetime.now()
start_date = now + timedelta(days=1)
due_date = now + timedelta(days=7)
todo_data = {
"summary": "Task with Dates",
"description": "Test task with various date fields",
"status": "NEEDS-ACTION",
"dtstart": start_date.strftime("%Y-%m-%dT09:00:00"),
"due": due_date.strftime("%Y-%m-%dT17:00:00"),
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
logger.info(f"Created todo with dates, UID: {todo_uid}")
# Verify dates
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert created_todo is not None
assert created_todo["summary"] == "Task with Dates"
assert "dtstart" in created_todo
assert "due" in created_todo
# Cleanup
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e:
logger.error(f"Date handling test failed: {e}")
raise
# ============= Advanced Feature Tests =============
async def test_todo_status_transitions(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test transitioning through different todo statuses."""
calendar_name = temporary_calendar
todo_data = {
"summary": "Status Transition Test",
"description": "Testing status changes",
"status": "NEEDS-ACTION",
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
try:
# Transition: NEEDS-ACTION → IN-PROCESS
await nc_client.calendar.update_todo(
calendar_name,
todo_uid,
{"status": "IN-PROCESS", "percent_complete": 25},
)
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert todo["status"] == "IN-PROCESS"
assert todo["percent_complete"] == 25
# Transition: IN-PROCESS → COMPLETED
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
await nc_client.calendar.update_todo(
calendar_name,
todo_uid,
{
"status": "COMPLETED",
"percent_complete": 100,
"completed": completed_time,
},
)
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert todo["status"] == "COMPLETED"
assert todo["percent_complete"] == 100
assert "completed" in todo
logger.info(f"Successfully transitioned todo through statuses: {todo_uid}")
finally:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
async def test_todo_priority_levels(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test different priority levels (0=undefined, 1=highest, 9=lowest)."""
calendar_name = temporary_calendar
priorities = [0, 1, 5, 9]
priority_labels = {0: "Undefined", 1: "Highest", 5: "Medium", 9: "Lowest"}
todo_uids = []
try:
# Create todos with different priorities
for priority in priorities:
todo_data = {
"summary": f"Priority {priority} Task ({priority_labels[priority]})",
"status": "NEEDS-ACTION",
"priority": priority,
}
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uids.append((result["uid"], priority))
# Verify all priorities
todos = await nc_client.calendar.list_todos(calendar_name)
for uid, expected_priority in todo_uids:
todo = next((t for t in todos if t["uid"] == uid), None)
assert todo is not None
assert todo["priority"] == expected_priority
logger.info(f"Successfully tested priority levels: {priorities}")
finally:
# Cleanup
for uid, _ in todo_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_todo_with_categories(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating a todo with multiple categories."""
calendar_name = temporary_calendar
todo_data = {
"summary": "Task with Categories",
"description": "Testing category support",
"status": "NEEDS-ACTION",
"categories": "work,meeting,important,quarterly",
}
try:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
todo_uid = result["uid"]
logger.info(f"Created todo with categories, UID: {todo_uid}")
# Verify categories
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next((t for t in todos if t["uid"] == todo_uid), None)
assert created_todo is not None
assert "categories" in created_todo
categories_str = created_todo["categories"]
assert "work" in categories_str
assert "meeting" in categories_str
assert "important" in categories_str
assert "quarterly" in categories_str
# Cleanup
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception as e:
logger.error(f"Categories test failed: {e}")
raise
async def test_search_todos_across_calendars(
nc_client: NextcloudClient, temporary_calendar: str, shared_calendar_2: str
):
"""Test searching for todos across multiple calendars.
Uses two shared test calendars to avoid rate limiting.
"""
# Use existing shared calendars to avoid rate limits
cal1_name = temporary_calendar # First shared test calendar
cal2_name = shared_calendar_2 # Second shared test calendar
try:
# Create todos in both calendars
todo1_data = {"summary": "Task in Calendar 1", "status": "NEEDS-ACTION"}
todo2_data = {"summary": "Task in Calendar 2", "status": "IN-PROCESS"}
result1 = await nc_client.calendar.create_todo(cal1_name, todo1_data)
result2 = await nc_client.calendar.create_todo(cal2_name, todo2_data)
# Search across all calendars
all_todos = await nc_client.calendar.search_todos_across_calendars()
assert isinstance(all_todos, list)
# Find our todos
todo1 = next((t for t in all_todos if t["uid"] == result1["uid"]), None)
todo2 = next((t for t in all_todos if t["uid"] == result2["uid"]), None)
assert todo1 is not None
assert todo2 is not None
assert "calendar_name" in todo1
assert "calendar_name" in todo2
assert todo1["calendar_name"] == cal1_name
assert todo2["calendar_name"] == cal2_name
logger.info(f"Found {len(all_todos)} todos across all calendars")
finally:
# Cleanup: Delete only the todos we created (calendars are reused/built-in)
try:
await nc_client.calendar.delete_todo(cal1_name, result1["uid"])
except Exception:
pass
try:
await nc_client.calendar.delete_todo(cal2_name, result2["uid"])
except Exception:
pass
# ============= Edge Case Tests =============
async def test_get_nonexistent_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test attempting to retrieve a non-existent todo."""
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
# List todos to ensure it doesn't exist
todos = await nc_client.calendar.list_todos(calendar_name)
matching_todos = [t for t in todos if t.get("uid") == fake_uid]
assert len(matching_todos) == 0
logger.info(f"Verified nonexistent todo UID: {fake_uid}")
async def test_delete_nonexistent_todo(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test deleting a non-existent todo."""
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
result = await nc_client.calendar.delete_todo(calendar_name, fake_uid)
assert result["status_code"] == 404
logger.info(f"Correctly got 404 for deleting nonexistent todo: {fake_uid}")
async def test_list_todos_with_filters(
nc_client: NextcloudClient, temporary_calendar: str
):
"""Test listing todos with various filters."""
calendar_name = temporary_calendar
# Create todos with different statuses and priorities
test_todos = [
{
"summary": "High Priority Task",
"status": "NEEDS-ACTION",
"priority": 1,
"categories": "urgent",
},
{
"summary": "In Progress Task",
"status": "IN-PROCESS",
"priority": 5,
"categories": "work",
},
{
"summary": "Low Priority Task",
"status": "NEEDS-ACTION",
"priority": 9,
"categories": "someday",
},
]
created_uids = []
try:
# Create test todos
for todo_data in test_todos:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
created_uids.append(result["uid"])
# Test basic list without filters
all_todos = await nc_client.calendar.list_todos(calendar_name)
assert len(all_todos) >= 3
# Verify all our todos are in the list
our_todo_uids = [t["uid"] for t in all_todos if t["uid"] in created_uids]
assert len(our_todo_uids) == 3
logger.info(f"Successfully created and listed {len(created_uids)} test todos")
finally:
# Cleanup
for uid in created_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
-482
View File
@@ -1,482 +0,0 @@
import httpx
# ============================================================================
# Mock Response Helpers for Unit Tests
# ============================================================================
def create_mock_response(
status_code: int = 200,
json_data: dict | list | None = None,
headers: dict | None = None,
content: bytes | None = None,
) -> httpx.Response:
"""Create a mock httpx.Response for testing.
Args:
status_code: HTTP status code
json_data: JSON data to return from response.json()
headers: Response headers
content: Raw response content (if not using json_data)
Returns:
Mock httpx.Response object
"""
import json as json_module
if headers is None:
headers = {}
# If json_data is provided, serialize it to content
if json_data is not None:
content = json_module.dumps(json_data).encode("utf-8")
headers.setdefault("content-type", "application/json")
if content is None:
content = b""
# Create a mock request
request = httpx.Request("GET", "http://test.local/api")
# Create the response
return httpx.Response(
status_code=status_code,
headers=headers,
content=content,
request=request,
)
def create_mock_note_response(
note_id: int = 1,
title: str = "Test Note",
content: str = "Test content",
category: str = "Test",
etag: str = "abc123",
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud note.
Args:
note_id: Note ID
title: Note title
content: Note content
category: Note category
etag: ETag header value
**kwargs: Additional note fields
Returns:
Mock httpx.Response with note data
"""
note_data = {
"id": note_id,
"title": title,
"content": content,
"category": category,
"etag": etag,
"modified": 1234567890,
"favorite": False,
**kwargs,
}
return create_mock_response(
status_code=200,
json_data=note_data,
headers={"etag": f'"{etag}"'},
)
def create_mock_error_response(
status_code: int,
message: str = "Error",
) -> httpx.Response:
"""Create a mock error response.
Args:
status_code: HTTP error status code (e.g., 404, 412)
message: Error message
Returns:
Mock httpx.Response with error
"""
return create_mock_response(
status_code=status_code,
json_data={"message": message},
)
def create_mock_recipe_response(
recipe_id: int = 1,
name: str = "Test Recipe",
description: str = "Test description",
recipe_category: str = "Test",
keywords: str = "test",
recipe_yield: int = 4,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Cookbook recipe.
Args:
recipe_id: Recipe ID
name: Recipe name
description: Recipe description
recipe_category: Recipe category
keywords: Recipe keywords (comma-separated)
recipe_yield: Recipe yield (number of servings)
**kwargs: Additional recipe fields (recipeIngredient, recipeInstructions, etc.)
Returns:
Mock httpx.Response with recipe data
"""
recipe_data = {
"id": recipe_id,
"name": name,
"description": description,
"recipeCategory": recipe_category,
"keywords": keywords,
"recipeYield": recipe_yield,
"recipeIngredient": kwargs.get("recipeIngredient", []),
"recipeInstructions": kwargs.get("recipeInstructions", []),
"prepTime": kwargs.get("prepTime", "PT15M"),
"cookTime": kwargs.get("cookTime", "PT30M"),
"totalTime": kwargs.get("totalTime", "PT45M"),
"url": kwargs.get("url", ""),
**{
k: v
for k, v in kwargs.items()
if k
not in [
"recipeIngredient",
"recipeInstructions",
"prepTime",
"cookTime",
"totalTime",
"url",
]
},
}
return create_mock_response(
status_code=200,
json_data=recipe_data,
)
def create_mock_recipe_list_response(
recipes: list[dict] = None,
) -> httpx.Response:
"""Create a mock response for a list of recipe stubs.
Args:
recipes: List of recipe stub dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with recipe list data
"""
if recipes is None:
recipes = []
return create_mock_response(
status_code=200,
json_data=recipes,
)
def create_mock_deck_board_response(
board_id: int = 1,
title: str = "Test Board",
color: str = "0000FF",
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Deck board.
Args:
board_id: Board ID
title: Board title
color: Board color (hex without #)
**kwargs: Additional board fields
Returns:
Mock httpx.Response with board data
"""
board_data = {
"id": board_id,
"title": title,
"color": color,
"owner": {
"primaryKey": "testuser",
"uid": "testuser",
"displayname": "Test User",
},
"archived": False,
"labels": [],
"acl": [],
"permissions": {
"PERMISSION_READ": True,
"PERMISSION_EDIT": True,
"PERMISSION_MANAGE": True,
"PERMISSION_SHARE": True,
},
"users": [],
"deletedAt": 0,
**kwargs,
}
return create_mock_response(status_code=200, json_data=board_data)
def create_mock_deck_stack_response(
stack_id: int = 1,
title: str = "Test Stack",
board_id: int = 1,
order: int = 1,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Deck stack.
Args:
stack_id: Stack ID
title: Stack title
board_id: Parent board ID
order: Stack order
**kwargs: Additional stack fields
Returns:
Mock httpx.Response with stack data
"""
stack_data = {
"id": stack_id,
"title": title,
"boardId": board_id,
"order": order,
"deletedAt": 0,
**kwargs,
}
return create_mock_response(status_code=200, json_data=stack_data)
def create_mock_deck_card_response(
card_id: int = 1,
title: str = "Test Card",
stack_id: int = 1,
description: str = "Test description",
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Deck card.
Args:
card_id: Card ID
title: Card title
stack_id: Parent stack ID
description: Card description
**kwargs: Additional card fields
Returns:
Mock httpx.Response with card data
"""
card_data = {
"id": card_id,
"title": title,
"stackId": stack_id,
"type": "plain",
"order": 999,
"archived": False,
"owner": "testuser",
"description": description,
"labels": [],
"assignedUsers": [],
**kwargs,
}
return create_mock_response(status_code=200, json_data=card_data)
def create_mock_deck_label_response(
label_id: int = 1,
title: str = "Test Label",
color: str = "FF0000",
board_id: int = 1,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Deck label.
Args:
label_id: Label ID
title: Label title
color: Label color (hex without #)
board_id: Parent board ID
**kwargs: Additional label fields
Returns:
Mock httpx.Response with label data
"""
label_data = {
"id": label_id,
"title": title,
"color": color,
"boardId": board_id,
**kwargs,
}
return create_mock_response(status_code=200, json_data=label_data)
def create_mock_deck_comment_response(
comment_id: int = 1,
message: str = "Test comment",
card_id: int = 1,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a Nextcloud Deck comment (OCS format).
Args:
comment_id: Comment ID
message: Comment message
card_id: Parent card ID
**kwargs: Additional comment fields
Returns:
Mock httpx.Response with comment data in OCS format
"""
comment_data = {
"id": comment_id,
"objectId": card_id,
"message": message,
"actorId": "testuser",
"actorDisplayName": "Test User",
"actorType": "users",
"creationDateTime": "2024-01-01T00:00:00+00:00",
"mentions": [], # Required field
**kwargs,
}
# Wrap in OCS format
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": comment_data}}
return create_mock_response(status_code=200, json_data=ocs_response)
def create_mock_tables_list_response(
tables: list[dict] = None,
) -> httpx.Response:
"""Create a mock response for list of Nextcloud Tables (OCS format).
Args:
tables: List of table dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with tables list data in OCS format
"""
if tables is None:
tables = []
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": tables}}
return create_mock_response(status_code=200, json_data=ocs_response)
def create_mock_table_schema_response(
table_id: int = 1,
columns: list[dict] = None,
**kwargs,
) -> httpx.Response:
"""Create a mock response for Nextcloud Tables schema.
Args:
table_id: Table ID
columns: List of column definitions. If None, creates sample columns.
**kwargs: Additional schema fields
Returns:
Mock httpx.Response with table schema data
"""
if columns is None:
columns = [
{"id": 1, "title": "Column 1", "type": "text"},
{"id": 2, "title": "Column 2", "type": "number"},
]
schema_data = {
"id": table_id,
"columns": columns,
**kwargs,
}
return create_mock_response(status_code=200, json_data=schema_data)
def create_mock_table_row_response(
row_id: int = 1,
table_id: int = 1,
data: list[dict] = None,
**kwargs,
) -> httpx.Response:
"""Create a mock response for Nextcloud Tables row.
Args:
row_id: Row ID
table_id: Table ID
data: List of column data dicts. If None, creates sample data.
**kwargs: Additional row fields
Returns:
Mock httpx.Response with row data
"""
if data is None:
data = [
{"columnId": 1, "value": "Test value"},
{"columnId": 2, "value": 42},
]
row_data = {
"id": row_id,
"tableId": table_id,
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
"data": data,
**kwargs,
}
return create_mock_response(status_code=200, json_data=row_data)
def create_mock_table_row_ocs_response(
row_id: int = 1,
table_id: int = 1,
data: list[dict] = None,
**kwargs,
) -> httpx.Response:
"""Create a mock OCS response for Nextcloud Tables row (used by create_row).
Args:
row_id: Row ID
table_id: Table ID
data: List of column data dicts. If None, creates sample data.
**kwargs: Additional row fields
Returns:
Mock httpx.Response with row data in OCS format
"""
if data is None:
data = [
{"columnId": 1, "value": "Test value"},
{"columnId": 2, "value": 42},
]
row_data = {
"id": row_id,
"tableId": table_id,
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
"data": data,
**kwargs,
}
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
return create_mock_response(status_code=200, json_data=ocs_response)
-371
View File
@@ -1,371 +0,0 @@
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.cookbook import CookbookClient
from tests.client.conftest import (
create_mock_error_response,
create_mock_recipe_list_response,
create_mock_recipe_response,
create_mock_response,
)
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
async def test_cookbook_version(mocker):
"""Test that get_version correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data={
"cookbook_version": "1.0.0",
"api_version": "1.0.0",
},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
version_data = await client.get_version()
assert "cookbook_version" in version_data
assert "api_version" in version_data
assert version_data["cookbook_version"] == "1.0.0"
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/version")
async def test_cookbook_config(mocker):
"""Test that get_config correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data={
"folder": "/recipes",
"update_interval": 60,
"print_image": True,
},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
config_data = await client.get_config()
assert isinstance(config_data, dict)
assert config_data["folder"] == "/recipes"
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/config")
async def test_cookbook_list_recipes(mocker):
"""Test that list_recipes correctly parses the API response."""
mock_response = create_mock_recipe_list_response(
recipes=[
{"id": 1, "name": "Recipe 1", "recipeCategory": "Test"},
{"id": 2, "name": "Recipe 2", "recipeCategory": "Test"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
recipes = await client.list_recipes()
assert isinstance(recipes, list)
assert len(recipes) == 2
assert recipes[0]["name"] == "Recipe 1"
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/recipes")
async def test_cookbook_create_recipe(mocker):
"""Test that create_recipe correctly parses the API response."""
# Create_recipe returns just the recipe ID
mock_response = create_mock_response(status_code=200, json_data=123)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
recipe_data = {
"name": "Test Recipe",
"description": "Test description",
"recipeIngredient": ["100g flour"],
"recipeInstructions": ["Mix ingredients"],
}
recipe_id = await client.create_recipe(recipe_data)
assert recipe_id == 123
mock_make_request.assert_called_once_with(
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
)
async def test_cookbook_get_recipe(mocker):
"""Test that get_recipe correctly parses the API response."""
mock_response = create_mock_recipe_response(
recipe_id=123,
name="Test Recipe",
description="Test description",
recipe_category="Test",
keywords="test,integration",
recipe_yield=4,
recipeIngredient=["100g flour", "2 eggs"],
recipeInstructions=["Mix ingredients", "Cook"],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
recipe = await client.get_recipe(recipe_id=123)
assert recipe["id"] == 123
assert recipe["name"] == "Test Recipe"
assert recipe["description"] == "Test description"
assert len(recipe["recipeIngredient"]) == 2
assert len(recipe["recipeInstructions"]) == 2
mock_make_request.assert_called_once_with(
"GET", "/apps/cookbook/api/v1/recipes/123"
)
async def test_cookbook_update_recipe(mocker):
"""Test that update_recipe correctly parses the API response."""
# Update_recipe returns the recipe ID
mock_response = create_mock_response(status_code=200, json_data=123)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
updated_data = {
"name": "Updated Recipe",
"description": "Updated description",
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
"recipeInstructions": ["Mix ingredients", "Cook", "Serve"],
}
updated_id = await client.update_recipe(recipe_id=123, recipe_data=updated_data)
assert updated_id == 123
mock_make_request.assert_called_once_with(
"PUT", "/apps/cookbook/api/v1/recipes/123", json=updated_data
)
async def test_cookbook_delete_recipe(mocker):
"""Test that delete_recipe correctly parses the API response."""
mock_response = create_mock_response(
status_code=200, json_data="Recipe deleted successfully"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
result = await client.delete_recipe(recipe_id=123)
assert isinstance(result, str)
assert "deleted" in result.lower()
mock_make_request.assert_called_once_with(
"DELETE", "/apps/cookbook/api/v1/recipes/123"
)
async def test_cookbook_delete_nonexistent_recipe(mocker):
"""Test that deleting a non-existent recipe raises HTTPStatusError."""
error_response = create_mock_error_response(404, "Recipe not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(CookbookClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("DELETE", "http://test.local"),
response=error_response,
)
client = CookbookClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.delete_recipe(recipe_id=999999999)
assert excinfo.value.response.status_code == 404
async def test_cookbook_search_recipes(mocker):
"""Test that search_recipes correctly parses the API response."""
mock_response = create_mock_recipe_list_response(
recipes=[
{"id": 1, "name": "Test Recipe 1", "keywords": "test,search"},
{"id": 2, "name": "Test Recipe 2", "keywords": "test,search"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
search_results = await client.search_recipes("test")
assert isinstance(search_results, list)
assert len(search_results) == 2
# Verify URL encoding happened
mock_make_request.assert_called_once()
call_args = mock_make_request.call_args[0]
assert "/apps/cookbook/api/v1/search/" in call_args[1]
async def test_cookbook_list_categories(mocker):
"""Test that list_categories correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{"name": "Desserts", "recipe_count": 5},
{"name": "Main Course", "recipe_count": 10},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
categories = await client.list_categories()
assert isinstance(categories, list)
assert len(categories) == 2
assert categories[0]["name"] == "Desserts"
assert categories[0]["recipe_count"] == 5
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/categories")
async def test_cookbook_get_recipes_in_category(mocker):
"""Test that get_recipes_in_category correctly parses the API response."""
mock_response = create_mock_recipe_list_response(
recipes=[
{"id": 1, "name": "Recipe 1", "recipeCategory": "Desserts"},
{"id": 2, "name": "Recipe 2", "recipeCategory": "Desserts"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
recipes_in_category = await client.get_recipes_in_category("Desserts")
assert isinstance(recipes_in_category, list)
assert len(recipes_in_category) == 2
assert recipes_in_category[0]["recipeCategory"] == "Desserts"
# Verify URL encoding happened
mock_make_request.assert_called_once()
call_args = mock_make_request.call_args[0]
assert "/apps/cookbook/api/v1/category/" in call_args[1]
async def test_cookbook_list_keywords(mocker):
"""Test that list_keywords correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{"name": "vegetarian", "recipe_count": 15},
{"name": "quick", "recipe_count": 8},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
keywords = await client.list_keywords()
assert isinstance(keywords, list)
assert len(keywords) == 2
assert keywords[0]["name"] == "vegetarian"
assert keywords[0]["recipe_count"] == 15
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/keywords")
async def test_cookbook_get_recipes_with_keywords(mocker):
"""Test that get_recipes_with_keywords correctly parses the API response."""
mock_response = create_mock_recipe_list_response(
recipes=[
{"id": 1, "name": "Recipe 1", "keywords": "vegetarian,quick"},
{"id": 2, "name": "Recipe 2", "keywords": "vegetarian,healthy"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
recipes_with_keywords = await client.get_recipes_with_keywords(
["vegetarian", "quick"]
)
assert isinstance(recipes_with_keywords, list)
assert len(recipes_with_keywords) == 2
# Verify URL encoding and keyword joining happened
mock_make_request.assert_called_once()
call_args = mock_make_request.call_args[0]
assert "/apps/cookbook/api/v1/tags/" in call_args[1]
async def test_cookbook_reindex(mocker):
"""Test that reindex correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data="Reindex completed successfully",
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
CookbookClient, "_make_request", return_value=mock_response
)
client = CookbookClient(mock_client, "testuser")
result = await client.reindex()
assert isinstance(result, str)
assert "reindex" in result.lower() or "completed" in result.lower()
mock_make_request.assert_called_once_with("POST", "/apps/cookbook/api/v1/reindex")
-511
View File
@@ -1,511 +0,0 @@
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.deck import DeckClient
from nextcloud_mcp_server.models.deck import (
DeckBoard,
DeckCard,
DeckComment,
DeckLabel,
DeckStack,
)
from tests.client.conftest import (
create_mock_deck_board_response,
create_mock_deck_card_response,
create_mock_deck_comment_response,
create_mock_deck_label_response,
create_mock_deck_stack_response,
create_mock_error_response,
create_mock_response,
)
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
# Board Tests
async def test_deck_get_boards(mocker):
"""Test that get_boards correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{
"id": 1,
"title": "Board 1",
"color": "FF0000",
"owner": {
"primaryKey": "testuser",
"uid": "testuser",
"displayname": "Test User",
},
"archived": False,
"labels": [],
"acl": [],
"permissions": {
"PERMISSION_READ": True,
"PERMISSION_EDIT": True,
"PERMISSION_MANAGE": True,
"PERMISSION_SHARE": True,
},
"users": [],
"deletedAt": 0,
},
{
"id": 2,
"title": "Board 2",
"color": "00FF00",
"owner": {
"primaryKey": "testuser",
"uid": "testuser",
"displayname": "Test User",
},
"archived": False,
"labels": [],
"acl": [],
"permissions": {
"PERMISSION_READ": True,
"PERMISSION_EDIT": True,
"PERMISSION_MANAGE": True,
"PERMISSION_SHARE": True,
},
"users": [],
"deletedAt": 0,
},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
boards = await client.get_boards()
assert isinstance(boards, list)
assert len(boards) == 2
assert all(isinstance(b, DeckBoard) for b in boards)
assert boards[0].id == 1
assert boards[0].title == "Board 1"
mock_make_request.assert_called_once()
async def test_deck_create_board(mocker):
"""Test that create_board correctly parses the API response."""
mock_response = create_mock_deck_board_response(
board_id=123, title="New Board", color="FF0000"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
board = await client.create_board(title="New Board", color="FF0000")
assert isinstance(board, DeckBoard)
assert board.id == 123
assert board.title == "New Board"
assert board.color == "FF0000"
mock_make_request.assert_called_once()
call_args = mock_make_request.call_args
assert call_args[0][0] == "POST"
assert call_args[1]["json"]["title"] == "New Board"
async def test_deck_get_board(mocker):
"""Test that get_board correctly parses the API response."""
mock_response = create_mock_deck_board_response(
board_id=123, title="Test Board", color="0000FF"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
board = await client.get_board(board_id=123)
assert isinstance(board, DeckBoard)
assert board.id == 123
assert board.title == "Test Board"
mock_make_request.assert_called_once()
assert "/boards/123" in mock_make_request.call_args[0][1]
async def test_deck_update_board(mocker):
"""Test that update_board makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
await client.update_board(board_id=123, title="Updated Board", color="00FF00")
mock_make_request.assert_called_once()
call_args = mock_make_request.call_args
assert call_args[0][0] == "PUT"
assert "/boards/123" in call_args[0][1]
assert call_args[1]["json"]["title"] == "Updated Board"
async def test_deck_get_board_nonexistent(mocker):
"""Test that getting a non-existent board raises HTTPStatusError."""
error_response = create_mock_error_response(404, "Board not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("GET", "http://test.local"),
response=error_response,
)
client = DeckClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.get_board(board_id=999999999)
assert excinfo.value.response.status_code == 404
# Stack Tests
async def test_deck_create_stack(mocker):
"""Test that create_stack correctly parses the API response."""
mock_response = create_mock_deck_stack_response(
stack_id=456, title="Test Stack", board_id=123, order=1
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
stack = await client.create_stack(board_id=123, title="Test Stack", order=1)
assert isinstance(stack, DeckStack)
assert stack.id == 456
assert stack.title == "Test Stack"
assert stack.boardId == 123
mock_make_request.assert_called_once()
async def test_deck_get_stack(mocker):
"""Test that get_stack correctly parses the API response."""
mock_response = create_mock_deck_stack_response(
stack_id=456, title="Test Stack", board_id=123, order=1
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
stack = await client.get_stack(board_id=123, stack_id=456)
assert isinstance(stack, DeckStack)
assert stack.id == 456
assert stack.title == "Test Stack"
mock_make_request.assert_called_once()
assert "/boards/123/stacks/456" in mock_make_request.call_args[0][1]
async def test_deck_get_stacks(mocker):
"""Test that get_stacks correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{"id": 1, "title": "Stack 1", "boardId": 123, "order": 1, "deletedAt": 0},
{"id": 2, "title": "Stack 2", "boardId": 123, "order": 2, "deletedAt": 0},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
stacks = await client.get_stacks(board_id=123)
assert isinstance(stacks, list)
assert len(stacks) == 2
assert all(isinstance(s, DeckStack) for s in stacks)
mock_make_request.assert_called_once()
# Card Tests
async def test_deck_create_card(mocker):
"""Test that create_card correctly parses the API response."""
mock_response = create_mock_deck_card_response(
card_id=789, title="Test Card", stack_id=456, description="Test description"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
card = await client.create_card(
board_id=123, stack_id=456, title="Test Card", description="Test description"
)
assert isinstance(card, DeckCard)
assert card.id == 789
assert card.title == "Test Card"
assert card.description == "Test description"
mock_make_request.assert_called_once()
async def test_deck_get_card(mocker):
"""Test that get_card correctly parses the API response."""
mock_response = create_mock_deck_card_response(
card_id=789, title="Test Card", stack_id=456
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
card = await client.get_card(board_id=123, stack_id=456, card_id=789)
assert isinstance(card, DeckCard)
assert card.id == 789
assert card.title == "Test Card"
mock_make_request.assert_called_once()
assert "/boards/123/stacks/456/cards/789" in mock_make_request.call_args[0][1]
async def test_deck_update_card(mocker):
"""Test that update_card makes the correct API calls."""
# Mock get_card response (update_card calls get_card first)
get_response = create_mock_deck_card_response(
card_id=789, title="Original Card", stack_id=456
)
# Mock update response
update_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
# First call returns the card, second call is the update
mock_make_request.side_effect = [get_response, update_response]
client = DeckClient(mock_client, "testuser")
await client.update_card(
board_id=123, stack_id=456, card_id=789, title="Updated Card"
)
# Should be called twice: GET then PUT
assert mock_make_request.call_count == 2
# Check the PUT call
put_call = mock_make_request.call_args_list[1]
assert put_call[0][0] == "PUT"
assert "/boards/123/stacks/456/cards/789" in put_call[0][1]
assert put_call[1]["json"]["title"] == "Updated Card"
# Label Tests
async def test_deck_create_label(mocker):
"""Test that create_label correctly parses the API response."""
mock_response = create_mock_deck_label_response(
label_id=111, title="Test Label", color="FF0000", board_id=123
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
label = await client.create_label(board_id=123, title="Test Label", color="FF0000")
assert isinstance(label, DeckLabel)
assert label.id == 111
assert label.title == "Test Label"
assert label.color == "FF0000"
mock_make_request.assert_called_once()
async def test_deck_get_label(mocker):
"""Test that get_label correctly parses the API response."""
mock_response = create_mock_deck_label_response(
label_id=111, title="Test Label", color="FF0000", board_id=123
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
label = await client.get_label(board_id=123, label_id=111)
assert isinstance(label, DeckLabel)
assert label.id == 111
assert label.title == "Test Label"
mock_make_request.assert_called_once()
assert "/boards/123/labels/111" in mock_make_request.call_args[0][1]
# Comment Tests
async def test_deck_create_comment(mocker):
"""Test that create_comment correctly parses the API response (OCS format)."""
mock_response = create_mock_deck_comment_response(
comment_id=222, message="Test comment", card_id=789
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
comment = await client.create_comment(card_id=789, message="Test comment")
assert isinstance(comment, DeckComment)
assert comment.id == 222
assert comment.message == "Test comment"
mock_make_request.assert_called_once()
async def test_deck_get_comments(mocker):
"""Test that get_comments correctly parses the API response (OCS format)."""
mock_response = create_mock_response(
status_code=200,
json_data={
"ocs": {
"meta": {"status": "ok"},
"data": [
{
"id": 1,
"objectId": 789,
"message": "Comment 1",
"actorId": "testuser",
"actorDisplayName": "Test User",
"actorType": "users",
"creationDateTime": "2024-01-01T00:00:00+00:00",
"mentions": [],
},
{
"id": 2,
"objectId": 789,
"message": "Comment 2",
"actorId": "testuser",
"actorDisplayName": "Test User",
"actorType": "users",
"creationDateTime": "2024-01-01T00:00:00+00:00",
"mentions": [],
},
],
}
},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
comments = await client.get_comments(card_id=789)
assert isinstance(comments, list)
assert len(comments) == 2
assert all(isinstance(c, DeckComment) for c in comments)
assert comments[0].message == "Comment 1"
mock_make_request.assert_called_once()
async def test_deck_update_comment(mocker):
"""Test that update_comment correctly parses the API response (OCS format)."""
mock_response = create_mock_deck_comment_response(
comment_id=222, message="Updated comment", card_id=789
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
comment = await client.update_comment(
card_id=789, comment_id=222, message="Updated comment"
)
assert isinstance(comment, DeckComment)
assert comment.id == 222
assert comment.message == "Updated comment"
mock_make_request.assert_called_once()
# Config Test
async def test_deck_get_config(mocker):
"""Test that get_config correctly parses the API response (OCS format)."""
mock_response = create_mock_response(
status_code=200,
json_data={
"ocs": {
"meta": {"status": "ok"},
"data": {
"calendar": True,
"cardDetailsInModal": True,
"cardIdBadge": False,
},
}
},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
DeckClient, "_make_request", return_value=mock_response
)
client = DeckClient(mock_client, "testuser")
config = await client.get_config()
assert config.calendar is True
assert config.cardDetailsInModal is True
assert config.cardIdBadge is False
mock_make_request.assert_called_once()
-255
View File
@@ -1,255 +0,0 @@
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.notes import NotesClient
from tests.client.conftest import create_mock_error_response, create_mock_note_response
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
async def test_notes_api_get_note(mocker):
"""Test that get_note correctly parses the API response."""
# Create mock response
mock_response = create_mock_note_response(
note_id=123,
title="Test Note",
content="Test content",
category="Test",
etag="abc123",
)
# Mock the _make_request method
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NotesClient, "_make_request", return_value=mock_response
)
# Create client and test
client = NotesClient(mock_client, "testuser")
note = await client.get_note(note_id=123)
# Verify the response was parsed correctly
assert note["id"] == 123
assert note["title"] == "Test Note"
assert note["content"] == "Test content"
assert note["category"] == "Test"
assert note["etag"] == "abc123"
# Verify the correct API endpoint was called
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
async def test_notes_api_create_note(mocker):
"""Test that create_note correctly parses the API response."""
mock_response = create_mock_note_response(
note_id=456,
title="New Note",
content="New content",
category="Category",
etag="def456",
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NotesClient, "_make_request", return_value=mock_response
)
client = NotesClient(mock_client, "testuser")
note = await client.create_note(
title="New Note", content="New content", category="Category"
)
assert note["id"] == 456
assert note["title"] == "New Note"
assert note["content"] == "New content"
assert note["category"] == "Category"
# Verify the correct API call was made
mock_make_request.assert_called_once_with(
"POST",
"/apps/notes/api/v1/notes",
json={"title": "New Note", "content": "New content", "category": "Category"},
)
async def test_notes_api_update(mocker):
"""Test that update correctly parses the API response and handles etag."""
# Mock the update response (no category passed, so no GET call happens)
update_response = create_mock_note_response(
note_id=123,
title="Updated Title",
content="Updated content",
category="Test",
etag="new_etag",
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
# Mock _make_request to return the update response
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
mock_make_request.return_value = update_response
client = NotesClient(mock_client, "testuser")
updated_note = await client.update(
note_id=123,
etag="abc123",
title="Updated Title",
content="Updated content",
)
assert updated_note["id"] == 123
assert updated_note["title"] == "Updated Title"
assert updated_note["content"] == "Updated content"
assert updated_note["etag"] == "new_etag"
# Verify the PUT request was made with the correct etag header (only 1 call since no category)
assert mock_make_request.call_count == 1
put_call = mock_make_request.call_args_list[0]
assert put_call[0] == ("PUT", "/apps/notes/api/v1/notes/123")
assert put_call[1]["headers"]["If-Match"] == '"abc123"'
async def test_notes_api_update_conflict(mocker):
"""Test that update raises HTTPStatusError on 412 conflict."""
# Mock the 412 error response
error_response = create_mock_error_response(412, "Precondition Failed")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"412 Precondition Failed",
request=httpx.Request("PUT", "http://test.local"),
response=error_response,
)
client = NotesClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.update(
note_id=123,
etag="old_etag",
title="This should fail",
)
assert excinfo.value.response.status_code == 412
async def test_notes_api_delete_note(mocker):
"""Test that delete_note makes the correct API call."""
# Mock get_note response (to fetch category for cleanup)
get_response = create_mock_note_response(note_id=123, category="Test")
# Mock delete response
delete_response = create_mock_note_response(note_id=123)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
mock_make_request.side_effect = [get_response, delete_response]
client = NotesClient(mock_client, "testuser")
await client.delete_note(note_id=123)
# Verify DELETE was called
assert any(call[0][0] == "DELETE" for call in mock_make_request.call_args_list)
async def test_notes_api_delete_nonexistent(mocker):
"""Test that deleting a non-existent note raises 404."""
# Mock 404 error when fetching note details
error_response = create_mock_error_response(404, "Not Found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("GET", "http://test.local"),
response=error_response,
)
client = NotesClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.delete_note(note_id=999999999)
assert excinfo.value.response.status_code == 404
async def test_notes_api_append_content(mocker):
"""Test that append_content correctly appends to existing content."""
# Mock get_note response (to fetch current content)
get_response = create_mock_note_response(
note_id=123,
content="Original content",
etag="old_etag",
)
# Mock update response with appended content
update_response = create_mock_note_response(
note_id=123,
content="Original content\n---\nAppended content",
etag="new_etag",
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
# First call: GET (from get_note), second call: PUT (from update)
mock_make_request.side_effect = [get_response, update_response]
client = NotesClient(mock_client, "testuser")
updated_note = await client.append_content(note_id=123, content="Appended content")
assert updated_note["content"] == "Original content\n---\nAppended content"
assert updated_note["etag"] == "new_etag"
async def test_notes_api_append_content_to_empty_note(mocker):
"""Test that appending to empty note doesn't add separator."""
# Mock get_note response with empty content
get_response = create_mock_note_response(
note_id=123,
content="",
etag="old_etag",
)
# Mock update response with just the appended text (no separator)
update_response = create_mock_note_response(
note_id=123,
content="First content",
etag="new_etag",
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
# First call: GET (from get_note), second call: PUT (from update)
mock_make_request.side_effect = [get_response, update_response]
client = NotesClient(mock_client, "testuser")
updated_note = await client.append_content(note_id=123, content="First content")
# For empty notes, no separator should be added
assert updated_note["content"] == "First content"
async def test_notes_api_append_content_nonexistent_note(mocker):
"""Test that appending to a non-existent note raises 404."""
error_response = create_mock_error_response(404, "Not Found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("GET", "http://test.local"),
response=error_response,
)
client = NotesClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.append_content(note_id=999999999, content="This should fail")
assert excinfo.value.response.status_code == 404
-326
View File
@@ -1,326 +0,0 @@
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.tables import TablesClient
from tests.client.conftest import (
create_mock_error_response,
create_mock_response,
create_mock_table_row_ocs_response,
create_mock_table_row_response,
create_mock_table_schema_response,
create_mock_tables_list_response,
)
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
async def test_tables_list_tables(mocker):
"""Test that list_tables correctly parses the API response (OCS format)."""
mock_response = create_mock_tables_list_response(
tables=[
{"id": 1, "title": "Table 1"},
{"id": 2, "title": "Table 2"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
tables = await client.list_tables()
assert isinstance(tables, list)
assert len(tables) == 2
assert tables[0]["id"] == 1
assert tables[0]["title"] == "Table 1"
mock_make_request.assert_called_once()
async def test_tables_get_schema(mocker):
"""Test that get_table_schema correctly parses the API response."""
mock_response = create_mock_table_schema_response(
table_id=123,
columns=[
{"id": 1, "title": "Name", "type": "text"},
{"id": 2, "title": "Age", "type": "number"},
{"id": 3, "title": "Email", "type": "text"},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
schema = await client.get_table_schema(table_id=123)
assert isinstance(schema, dict)
assert "columns" in schema
assert len(schema["columns"]) == 3
assert schema["columns"][0]["title"] == "Name"
mock_make_request.assert_called_once()
assert "/tables/123/scheme" in mock_make_request.call_args[0][1]
async def test_tables_get_rows(mocker):
"""Test that get_table_rows correctly parses the API response."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{
"id": 1,
"tableId": 123,
"data": [
{"columnId": 1, "value": "John"},
{"columnId": 2, "value": 30},
],
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
},
{
"id": 2,
"tableId": 123,
"data": [
{"columnId": 1, "value": "Jane"},
{"columnId": 2, "value": 25},
],
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
rows = await client.get_table_rows(table_id=123)
assert isinstance(rows, list)
assert len(rows) == 2
assert rows[0]["id"] == 1
assert rows[0]["tableId"] == 123
mock_make_request.assert_called_once()
async def test_tables_get_rows_with_pagination(mocker):
"""Test that get_table_rows correctly handles pagination parameters."""
mock_response = create_mock_response(
status_code=200,
json_data=[
{
"id": 1,
"tableId": 123,
"data": [],
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
rows = await client.get_table_rows(table_id=123, limit=5, offset=10)
assert isinstance(rows, list)
# Verify pagination parameters were passed
call_args = mock_make_request.call_args
assert call_args[1]["params"]["limit"] == 5
assert call_args[1]["params"]["offset"] == 10
async def test_tables_create_row(mocker):
"""Test that create_row correctly parses the API response (OCS format)."""
mock_response = create_mock_table_row_ocs_response(
row_id=456,
table_id=123,
data=[
{"columnId": 1, "value": "Test Name"},
{"columnId": 2, "value": 99},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
test_data = {1: "Test Name", 2: 99}
created_row = await client.create_row(table_id=123, data=test_data)
assert isinstance(created_row, dict)
assert created_row["id"] == 456
assert created_row["tableId"] == 123
# Verify the data was transformed to string keys
call_args = mock_make_request.call_args
assert call_args[1]["json"]["data"]["1"] == "Test Name"
assert call_args[1]["json"]["data"]["2"] == 99
async def test_tables_update_row(mocker):
"""Test that update_row correctly parses the API response."""
mock_response = create_mock_table_row_response(
row_id=456,
table_id=123,
data=[
{"columnId": 1, "value": "Updated Name"},
{"columnId": 2, "value": 100},
],
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
update_data = {1: "Updated Name", 2: 100}
updated_row = await client.update_row(row_id=456, data=update_data)
assert isinstance(updated_row, dict)
assert updated_row["id"] == 456
# Verify the PUT request was made
call_args = mock_make_request.call_args
assert call_args[0][0] == "PUT"
assert "/rows/456" in call_args[0][1]
async def test_tables_delete_row(mocker):
"""Test that delete_row correctly parses the API response."""
mock_response = create_mock_response(
status_code=200, json_data={"message": "Row deleted"}
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
TablesClient, "_make_request", return_value=mock_response
)
client = TablesClient(mock_client, "testuser")
result = await client.delete_row(row_id=456)
assert isinstance(result, dict)
# Verify the DELETE request was made
call_args = mock_make_request.call_args
assert call_args[0][0] == "DELETE"
assert "/rows/456" in call_args[0][1]
async def test_tables_delete_nonexistent_row(mocker):
"""Test that deleting a non-existent row raises HTTPStatusError."""
error_response = create_mock_error_response(404, "Row not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("DELETE", "http://test.local"),
response=error_response,
)
client = TablesClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.delete_row(row_id=999999999)
assert excinfo.value.response.status_code == 404
async def test_tables_get_nonexistent_schema(mocker):
"""Test that getting schema for non-existent table raises HTTPStatusError."""
error_response = create_mock_error_response(404, "Table not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("GET", "http://test.local"),
response=error_response,
)
client = TablesClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.get_table_schema(table_id=999999999)
assert excinfo.value.response.status_code == 404
def test_tables_transform_row_data():
"""Test the transform_row_data utility method (synchronous)."""
# This is a pure function, no mocking needed
client = TablesClient(None, "testuser") # Client not used for this method
raw_rows = [
{
"id": 1,
"tableId": 123,
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
"data": [
{"columnId": 1, "value": "John Doe"},
{"columnId": 2, "value": 30},
{"columnId": 3, "value": "john@example.com"},
],
},
{
"id": 2,
"tableId": 123,
"createdBy": "testuser",
"createdAt": "2024-01-01T00:00:00+00:00",
"lastEditBy": "testuser",
"lastEditAt": "2024-01-01T00:00:00+00:00",
"data": [
{"columnId": 1, "value": "Jane Smith"},
{"columnId": 2, "value": 25},
{"columnId": 3, "value": "jane@example.com"},
],
},
]
columns = [
{"id": 1, "title": "Name", "type": "text"},
{"id": 2, "title": "Age", "type": "number"},
{"id": 3, "title": "Email", "type": "text"},
]
transformed = client.transform_row_data(raw_rows, columns)
assert len(transformed) == 2
assert transformed[0]["id"] == 1
assert transformed[0]["data"]["Name"] == "John Doe"
assert transformed[0]["data"]["Age"] == 30
assert transformed[0]["data"]["Email"] == "john@example.com"
assert transformed[1]["data"]["Name"] == "Jane Smith"
assert transformed[1]["data"]["Age"] == 25
-103
View File
@@ -1,103 +0,0 @@
"""Integration tests for OAuth authentication."""
import logging
import os
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.auth import BearerAuth
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
# OAuth Client Tests
async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can fetch capabilities."""
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
assert "ocs" in capabilities
logger.info(
f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}"
)
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can list notes."""
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client successfully listed {len(notes)} notes")
async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient):
"""Test that OAuth client can create and delete a note."""
# Create note
note_title = "OAuth Test Note"
note_content = "This note was created with OAuth authentication"
created_note = await nc_oauth_client.notes.create_note(
title=note_title, content=note_content
)
assert created_note is not None
assert created_note.get("title") == note_title
note_id = created_note.get("id")
assert note_id is not None
logger.info(f"OAuth client successfully created note with ID: {note_id}")
# Clean up - delete the note
try:
await nc_oauth_client.notes.delete_note(note_id=note_id)
logger.info(f"OAuth client successfully deleted note {note_id}")
except Exception as e:
logger.error(f"Failed to clean up test note {note_id}: {e}")
raise
# OAuth Token Validation Tests
async def test_token_in_request_headers(
nc_oauth_client: NextcloudClient, playwright_oauth_token: str
):
"""Verify that bearer token is being used in requests."""
# The client should be using BearerAuth
assert nc_oauth_client._client.auth is not None
# Make a request and verify it works
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth bearer token is correctly included in requests")
async def test_invalid_token_fails():
"""Test that an invalid token results in authentication failure."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("NEXTCLOUD_HOST not set")
# Create client with invalid token using BearerAuth
invalid_client = NextcloudClient(
base_url=nextcloud_host,
username="testuser",
auth=BearerAuth("invalid_token_12345"),
)
# Attempt to use a protected endpoint - should fail with 401
# Note: capabilities endpoint is public and doesn't require auth
with pytest.raises(HTTPStatusError) as exc_info:
_ = [note async for note in invalid_client.notes.get_all_notes()]
assert exc_info.value.response.status_code == 401
await invalid_client.close()
logger.info("Invalid OAuth token correctly rejected")
-32
View File
@@ -1,32 +0,0 @@
"""Integration tests for Playwright-based OAuth authentication."""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
"""Test that Playwright can acquire an OAuth token automatically."""
assert playwright_oauth_token is not None
assert isinstance(playwright_oauth_token, str)
assert len(playwright_oauth_token) > 0
logger.info(
f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..."
)
async def test_oauth_client_with_playwright_flow(nc_oauth_client):
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
# Test 1: Check capabilities
capabilities = await nc_oauth_client.capabilities()
assert capabilities is not None
logger.info("OAuth client (Playwright) successfully fetched capabilities")
# Test 2: List notes
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
assert isinstance(notes, list)
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
-169
View File
@@ -1,169 +0,0 @@
"""Integration tests for Nextcloud Sharing API client."""
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
async def test_create_and_delete_share(nc_client):
"""Test creating and deleting a file share."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_share_file.txt"
file_content = b"Test file for sharing"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user, # Share with test user
share_type=0, # User share
permissions=1, # Read-only
)
assert share_data is not None
assert "id" in share_data
share_id = share_data["id"]
logger.info(f"Created share: {share_id}")
# Get share info
share_info = await nc_client.sharing.get_share(share_id)
assert share_info["id"] == share_id
assert share_info["path"] == file_path
assert share_info["permissions"] == 1
# List shares
shares = await nc_client.sharing.list_shares(path=file_path)
assert len(shares) > 0
assert any(s["id"] == share_id for s in shares)
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
logger.info(f"Deleted share: {share_id}")
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
async def test_update_share_permissions(nc_client):
"""Test updating share permissions."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_share_update.txt"
file_content = b"Test file for permission updates"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share with read-only permissions
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user,
share_type=0,
permissions=1, # Read-only
)
share_id = share_data["id"]
# Update to read+write permissions
updated_share = await nc_client.sharing.update_share(
share_id=share_id,
permissions=3, # Read + Write
)
assert updated_share["id"] == share_id
assert updated_share["permissions"] == 3
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
async def test_list_shares(nc_client):
"""Test listing all shares."""
# Create a test user to share with
test_user = "testuser3"
try:
await nc_client.users.create_user(
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
)
except Exception:
pass # User might already exist
# Create a test file
file_path = "/test_list_shares.txt"
file_content = b"Test file for listing shares"
await nc_client.webdav.write_file(file_path, file_content)
share_id = None
try:
# Create a share
share_data = await nc_client.sharing.create_share(
path=file_path,
share_with=test_user,
share_type=0,
permissions=1,
)
share_id = share_data["id"]
# List all shares
all_shares = await nc_client.sharing.list_shares()
assert len(all_shares) > 0
# List shares for specific file
file_shares = await nc_client.sharing.list_shares(path=file_path)
assert len(file_shares) > 0
assert any(s["id"] == share_id for s in file_shares)
finally:
# Cleanup
if share_id:
await nc_client.sharing.delete_share(share_id)
await nc_client.webdav.delete_resource(file_path)
# Cleanup test user
try:
await nc_client.users.delete_user(test_user)
except Exception:
pass
-268
View File
@@ -1,268 +0,0 @@
"""Integration tests for WebDAV search operations."""
import logging
import uuid
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
# Mark all tests in this module as integration tests
pytestmark = pytest.mark.integration
@pytest.fixture
async def test_search_setup(nc_client: NextcloudClient):
"""Create test files and directories for search testing."""
test_dir = f"mcp_search_test_{uuid.uuid4().hex[:8]}"
# Create base directory
await nc_client.webdav.create_directory(test_dir)
# Create various test files
test_files = [
# Text files
(f"{test_dir}/document1.txt", b"Sample document content", "text/plain"),
(f"{test_dir}/document2.txt", b"Another document", "text/plain"),
(f"{test_dir}/report.txt", b"Report content", "text/plain"),
# Markdown files
(f"{test_dir}/readme.md", b"# README\nMarkdown content", "text/markdown"),
(f"{test_dir}/notes.md", b"# Notes\nSome notes here", "text/markdown"),
# PDF (simulated as binary)
(
f"{test_dir}/presentation.pdf",
b"%PDF-1.4 fake pdf content",
"application/pdf",
),
# Subdirectory with files
(f"{test_dir}/subdir/nested.txt", b"Nested file content", "text/plain"),
]
# Create subdirectory
await nc_client.webdav.create_directory(f"{test_dir}/subdir")
# Write all test files
for file_path, content, content_type in test_files:
await nc_client.webdav.write_file(file_path, content, content_type)
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
yield test_dir
# Cleanup
try:
await nc_client.webdav.delete_resource(test_dir)
logger.info(f"Cleaned up test directory: {test_dir}")
except Exception as e:
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
"""Test finding files by exact name."""
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
assert len(results) >= 1, "Should find at least one readme.md file"
# Check that we found the right file
readme_files = [r for r in results if r.get("name") == "readme.md"]
assert len(readme_files) >= 1, "Should find readme.md"
logger.info(f"Found {len(results)} files matching 'readme.md'")
async def test_find_by_name_wildcard_extension(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by extension using wildcard."""
# Find all .txt files
results = await nc_client.webdav.find_by_name("%.txt", scope=test_search_setup)
assert len(results) >= 3, "Should find at least 3 .txt files"
# Verify all results are .txt files
for result in results:
name = result.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
logger.info(f"Found {len(results)} .txt files")
async def test_find_by_name_wildcard_prefix(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by name prefix using wildcard."""
# Find all files starting with "document"
results = await nc_client.webdav.find_by_name("document%", scope=test_search_setup)
assert len(results) >= 2, "Should find at least 2 files starting with 'document'"
# Verify all results start with "document"
for result in results:
name = result.get("name", "")
assert name.startswith("document"), (
f"Expected name to start with 'document', got {name}"
)
logger.info(f"Found {len(results)} files starting with 'document'")
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
"""Test finding files by MIME type (text files)."""
# Find all text files
results = await nc_client.webdav.find_by_type("text/%", scope=test_search_setup)
assert len(results) >= 5, "Should find at least 5 text files"
# Verify all results are text files
for result in results:
content_type = result.get("content_type", "")
assert content_type.startswith("text/"), (
f"Expected text/* type, got {content_type}"
)
logger.info(f"Found {len(results)} text files")
async def test_find_by_type_specific(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test finding files by specific MIME type."""
# Find PDF files
results = await nc_client.webdav.find_by_type(
"application/pdf", scope=test_search_setup
)
assert len(results) >= 1, "Should find at least 1 PDF file"
# Verify result is PDF
for result in results:
content_type = result.get("content_type", "")
assert content_type == "application/pdf", (
f"Expected application/pdf, got {content_type}"
)
logger.info(f"Found {len(results)} PDF files")
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
"""Test search with result limit."""
# Search for .txt files with limit of 2
results = await nc_client.webdav.find_by_name(
"%.txt", scope=test_search_setup, limit=2
)
# Should return at most 2 results
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
assert len(results) > 0, "Should return at least 1 result"
logger.info(f"Found {len(results)} files with limit=2")
async def test_search_files_combined_filters(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test search with multiple filters combined."""
# This test uses the search_files method directly to test combined conditions
# Search for .txt files that match a specific pattern
where_conditions = """
<d:and>
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>%.txt</d:literal>
</d:like>
<d:like>
<d:prop>
<d:displayname/>
</d:prop>
<d:literal>document%</d:literal>
</d:like>
</d:and>
"""
results = await nc_client.webdav.search_files(
scope=test_search_setup, where_conditions=where_conditions
)
# Should find document1.txt and document2.txt
assert len(results) >= 2, "Should find at least 2 files matching both conditions"
# Verify results match both conditions
for result in results:
name = result.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
assert name.startswith("document"), (
f"Expected name to start with 'document', got {name}"
)
logger.info(f"Found {len(results)} files matching combined filters")
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
"""Test search in empty scope (user root)."""
# Search entire user root for a unique filename
unique_name = "readme.md"
results = await nc_client.webdav.find_by_name(unique_name, scope="")
# Should find at least the one we created
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
logger.info(f"Found {len(results)} files in root scope")
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
"""Test search within a subdirectory."""
# Search in the subdir for the nested file
results = await nc_client.webdav.find_by_name(
"nested.txt", scope=f"{test_search_setup}/subdir"
)
assert len(results) >= 1, "Should find nested.txt in subdirectory"
# Verify the file path
nested_file = results[0]
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
"""Test search that returns no results."""
# Search for a non-existent pattern
results = await nc_client.webdav.find_by_name(
"nonexistent_file_xyz123.txt", scope=test_search_setup
)
assert len(results) == 0, "Should return empty results for non-existent file"
logger.info("Search correctly returned no results for non-existent file")
async def test_search_properties_returned(
nc_client: NextcloudClient, test_search_setup: str
):
"""Test that search returns expected properties."""
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
assert len(results) >= 1, "Should find at least one file"
result = results[0]
# Check for expected properties
assert "name" in result, "Should include name property"
assert "path" in result, "Should include path property"
assert "is_directory" in result, "Should include is_directory property"
assert result["is_directory"] is False, "readme.md should not be a directory"
# Optional properties that may be present
optional_props = ["size", "content_type", "last_modified", "etag"]
logger.info(f"Result properties: {list(result.keys())}")
# At least some optional properties should be present
present_optional = [prop for prop in optional_props if prop in result]
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
logger.info(f"Search returned properties: {list(result.keys())}")
+35 -2095
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type text/html;
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
try_files $uri $uri.html =404;
}
# Serve test_recipe.html at /black-pepper-tofu
location = /black-pepper-tofu {
root /usr/share/nginx/html;
try_files /test_recipe.html =404;
}
}
}
-133
View File
@@ -1,133 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Black Pepper Tofu Recipe - Test Recipe</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Black Pepper Tofu",
"author": {
"@type": "Person",
"name": "Yotam Ottolenghi"
},
"datePublished": "2024-01-15",
"description": "A flavorful black pepper tofu dish with aromatic spices and crispy texture. Inspired by Yotam Ottolenghi's signature style.",
"prepTime": "PT15M",
"cookTime": "PT20M",
"totalTime": "PT35M",
"recipeYield": "4",
"recipeCategory": "Main Course",
"recipeCuisine": "Asian Fusion",
"keywords": "tofu, black pepper, vegetarian, vegan, ottolenghi",
"image": "https://example.com/black-pepper-tofu.jpg",
"recipeIngredient": [
"400g firm tofu, pressed and cubed",
"2 tablespoons black peppercorns, coarsely ground",
"3 tablespoons soy sauce",
"2 tablespoons rice vinegar",
"1 tablespoon maple syrup",
"2 tablespoons cornstarch",
"3 tablespoons vegetable oil",
"4 cloves garlic, minced",
"1 tablespoon fresh ginger, grated",
"2 spring onions, sliced",
"1 red bell pepper, sliced",
"Sesame seeds for garnish"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes."
},
{
"@type": "HowToStep",
"text": "Toss tofu cubes with cornstarch until evenly coated."
},
{
"@type": "HowToStep",
"text": "Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside."
},
{
"@type": "HowToStep",
"text": "In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant."
},
{
"@type": "HowToStep",
"text": "Add bell pepper and cook for 2-3 minutes until slightly softened."
},
{
"@type": "HowToStep",
"text": "Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer."
},
{
"@type": "HowToStep",
"text": "Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes."
},
{
"@type": "HowToStep",
"text": "Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles."
}
],
"nutrition": {
"@type": "NutritionInformation",
"calories": "280 kcal",
"proteinContent": "18 g",
"fatContent": "16 g",
"carbohydrateContent": "18 g",
"fiberContent": "3 g",
"servingSize": "1 serving"
}
}
</script>
</head>
<body>
<article>
<h1>Black Pepper Tofu</h1>
<p class="author">By Yotam Ottolenghi</p>
<p class="description">
A flavorful black pepper tofu dish with aromatic spices and crispy texture.
Inspired by Yotam Ottolenghi's signature style.
</p>
<div class="recipe-meta">
<p><strong>Prep Time:</strong> 15 minutes</p>
<p><strong>Cook Time:</strong> 20 minutes</p>
<p><strong>Total Time:</strong> 35 minutes</p>
<p><strong>Servings:</strong> 4</p>
</div>
<h2>Ingredients</h2>
<ul>
<li>400g firm tofu, pressed and cubed</li>
<li>2 tablespoons black peppercorns, coarsely ground</li>
<li>3 tablespoons soy sauce</li>
<li>2 tablespoons rice vinegar</li>
<li>1 tablespoon maple syrup</li>
<li>2 tablespoons cornstarch</li>
<li>3 tablespoons vegetable oil</li>
<li>4 cloves garlic, minced</li>
<li>1 tablespoon fresh ginger, grated</li>
<li>2 spring onions, sliced</li>
<li>1 red bell pepper, sliced</li>
<li>Sesame seeds for garnish</li>
</ul>
<h2>Instructions</h2>
<ol>
<li>Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.</li>
<li>Toss tofu cubes with cornstarch until evenly coated.</li>
<li>Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.</li>
<li>In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.</li>
<li>Add bell pepper and cook for 2-3 minutes until slightly softened.</li>
<li>Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.</li>
<li>Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.</li>
<li>Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.</li>
</ol>
<h2>Nutrition Information</h2>
<p>Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber</p>
</article>
</body>
</html>
@@ -1,9 +1,4 @@
"""Integration tests for Calendar CalDAV operations.
Note: These tests use the shared temporary_calendar fixture from conftest.py
which reuses a session-scoped calendar to avoid Nextcloud rate limiting issues.
Each test cleans up its own events/todos but shares the same calendar.
"""
"""Integration tests for Calendar CalDAV operations."""
import logging
import uuid
@@ -21,12 +16,49 @@ pytestmark = pytest.mark.integration
@pytest.fixture
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary event for testing and clean up afterward.
def test_calendar_name():
"""Unique calendar name for testing."""
return f"test_calendar_{uuid.uuid4().hex[:8]}"
Uses the shared temporary_calendar fixture from conftest.py which reuses
a session-scoped calendar to avoid Nextcloud rate limiting.
"""
@pytest.fixture
async def temporary_calendar(nc_client: NextcloudClient, test_calendar_name: str):
"""Create a temporary calendar for testing and clean up afterward."""
calendar_name = test_calendar_name
try:
# Create a test calendar
logger.info(f"Creating temporary calendar: {calendar_name}")
result = await nc_client.calendar.create_calendar(
calendar_name=calendar_name,
display_name=f"Test Calendar {calendar_name}",
description="Temporary calendar for integration testing",
color="#FF5722",
)
if result["status_code"] not in [200, 201]:
pytest.skip(f"Failed to create temporary calendar: {result}")
logger.info(f"Created temporary calendar: {calendar_name}")
yield calendar_name
except Exception as e:
logger.error(f"Error setting up temporary calendar: {e}")
pytest.skip(f"Calendar setup failed: {e}")
finally:
# Cleanup: Delete the temporary calendar
try:
logger.info(f"Cleaning up temporary calendar: {calendar_name}")
await nc_client.calendar.delete_calendar(calendar_name)
logger.info(f"Successfully deleted temporary calendar: {calendar_name}")
except Exception as e:
logger.error(f"Error deleting temporary calendar {calendar_name}: {e}")
@pytest.fixture
async def temporary_event(nc_client: NextcloudClient, temporary_calendar: str):
"""Create a temporary event for testing and clean up afterward."""
event_uid = None
calendar_name = temporary_calendar
@@ -319,11 +351,11 @@ async def test_get_nonexistent_event(
calendar_name = temporary_calendar
fake_uid = f"nonexistent-{uuid.uuid4()}"
# caldav library raises generic Exception for missing events, not HTTPStatusError
with pytest.raises(Exception, match="not found"):
with pytest.raises(HTTPStatusError) as exc_info:
await nc_client.calendar.get_event(calendar_name, fake_uid)
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
assert exc_info.value.response.status_code == 404
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
async def test_delete_nonexistent_event(
@@ -388,11 +420,7 @@ async def test_calendar_operations_error_handling(
# Test with non-existent calendar
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
# caldav library returns empty list for non-existent calendars, doesn't raise
# Testing that it doesn't crash and returns empty results
events = await nc_client.calendar.get_calendar_events(fake_calendar)
assert isinstance(events, list)
# Empty list is expected for non-existent calendar
assert len(events) == 0
with pytest.raises(HTTPStatusError):
await nc_client.calendar.get_calendar_events(fake_calendar)
logger.info("Error handling tests completed successfully")
+327
View File
@@ -0,0 +1,327 @@
import logging
import uuid
import pytest
from httpx import HTTPStatusError
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.models.deck import DeckStack, DeckCard, DeckLabel
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
# Board CRUD Tests
async def test_deck_board_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete board CRUD workflow using the temporary_board fixture.
"""
board_data = temporary_board
board_id = board_data["id"]
original_title = board_data["title"]
original_color = board_data["color"]
logger.info(f"Testing CRUD operations on board ID: {board_id}")
# Read the board
read_board = await nc_client.deck.get_board(board_id)
assert read_board.id == board_id
assert read_board.title == original_title
assert read_board.color == original_color
logger.info(f"Successfully read board ID: {board_id}")
# Update the board
updated_title = f"Updated {original_title}"
updated_color = "00FF00" # Green color
await nc_client.deck.update_board(
board_id, title=updated_title, color=updated_color
)
# Verify the update
updated_board = await nc_client.deck.get_board(board_id)
assert updated_board.title == updated_title
assert updated_board.color == updated_color
logger.info(f"Successfully updated board ID: {board_id}")
async def test_deck_list_boards(nc_client: NextcloudClient):
"""
Test listing all boards with different options.
"""
# Test basic listing
boards = await nc_client.deck.get_boards()
assert isinstance(boards, list)
logger.info(f"Found {len(boards)} boards")
# Test with details
detailed_boards = await nc_client.deck.get_boards(details=True)
assert isinstance(detailed_boards, list)
logger.info(f"Found {len(detailed_boards)} boards with details")
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
"""
Test operations on non-existent board return appropriate errors.
"""
non_existent_id = 999999999
# Test get non-existent board
with pytest.raises(HTTPStatusError) as excinfo:
await nc_client.deck.get_board(non_existent_id)
assert excinfo.value.response.status_code in [
404,
403,
] # 403 might be returned for access denied
logger.info(
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
)
# Test update non-existent board
with pytest.raises(HTTPStatusError) as excinfo:
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
assert excinfo.value.response.status_code in [
404,
403,
400,
] # 400 for bad request on invalid board ID
logger.info(
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
)
# Stack CRUD Tests
async def test_deck_stack_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete stack CRUD workflow.
"""
board_id = temporary_board["id"]
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
stack_order = 1
stack = None
try:
# Create stack
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
assert isinstance(stack, DeckStack)
assert stack.title == stack_title
assert stack.order == stack_order
stack_id = stack.id
logger.info(f"Created stack ID: {stack_id}")
# Read stack
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
assert read_stack.id == stack_id
assert read_stack.title == stack_title
logger.info(f"Successfully read stack ID: {stack_id}")
# Update stack
updated_title = f"Updated {stack_title}"
updated_order = 2
await nc_client.deck.update_stack(
board_id, stack_id, title=updated_title, order=updated_order
)
# Verify update
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
assert updated_stack.title == updated_title
assert updated_stack.order == updated_order
logger.info(f"Successfully updated stack ID: {stack_id}")
# List stacks
stacks = await nc_client.deck.get_stacks(board_id)
assert isinstance(stacks, list)
assert any(s.id == stack_id for s in stacks)
logger.info(f"Found stack ID: {stack_id} in board stacks list")
finally:
# Clean up - delete stack
if stack and hasattr(stack, "id"):
try:
await nc_client.deck.delete_stack(board_id, stack.id)
logger.info(f"Cleaned up stack ID: {stack.id}")
except Exception as e:
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
# Card CRUD Tests
async def test_deck_card_crud_workflow(
nc_client: NextcloudClient, temporary_board_with_stack: tuple
):
"""
Test complete card CRUD workflow.
"""
board_data, stack_data = temporary_board_with_stack
board_id = board_data["id"]
stack_id = stack_data["id"]
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
card = None
try:
# Create card
card = await nc_client.deck.create_card(
board_id, stack_id, card_title, description=card_description
)
assert isinstance(card, DeckCard)
assert card.title == card_title
assert card.description == card_description
card_id = card.id
logger.info(f"Created card ID: {card_id}")
# Read card
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
assert read_card.id == card_id
assert read_card.title == card_title
logger.info(f"Successfully read card ID: {card_id}")
# Update card
updated_title = f"Updated {card_title}"
updated_description = f"Updated description for {card_title}"
await nc_client.deck.update_card(
board_id,
stack_id,
card_id,
title=updated_title,
description=updated_description,
)
# Verify update
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
assert updated_card.title == updated_title
assert updated_card.description == updated_description
logger.info(f"Successfully updated card ID: {card_id}")
# Archive and unarchive card
await nc_client.deck.archive_card(board_id, stack_id, card_id)
logger.info(f"Archived card ID: {card_id}")
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
logger.info(f"Unarchived card ID: {card_id}")
finally:
# Clean up - delete card
if card and hasattr(card, "id"):
try:
await nc_client.deck.delete_card(board_id, stack_id, card.id)
logger.info(f"Cleaned up card ID: {card.id}")
except Exception as e:
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
# Label CRUD Tests
async def test_deck_label_crud_workflow(
nc_client: NextcloudClient, temporary_board: dict
):
"""
Test complete label CRUD workflow.
"""
board_id = temporary_board["id"]
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
label_color = "FF0000" # Red
label = None
try:
# Create label
label = await nc_client.deck.create_label(board_id, label_title, label_color)
assert isinstance(label, DeckLabel)
assert label.title == label_title
assert label.color == label_color
label_id = label.id
logger.info(f"Created label ID: {label_id}")
# Read label
read_label = await nc_client.deck.get_label(board_id, label_id)
assert read_label.id == label_id
assert read_label.title == label_title
logger.info(f"Successfully read label ID: {label_id}")
# Update label
updated_title = f"Updated {label_title}"
updated_color = "00FF00" # Green
await nc_client.deck.update_label(
board_id, label_id, title=updated_title, color=updated_color
)
# Verify update
updated_label = await nc_client.deck.get_label(board_id, label_id)
assert updated_label.title == updated_title
assert updated_label.color == updated_color
logger.info(f"Successfully updated label ID: {label_id}")
finally:
# Clean up - delete label
if label and hasattr(label, "id"):
try:
await nc_client.deck.delete_label(board_id, label.id)
logger.info(f"Cleaned up label ID: {label.id}")
except Exception as e:
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
# Configuration and Comments Tests
async def test_deck_config_operations(nc_client: NextcloudClient):
"""
Test deck configuration operations.
"""
# Get config
config = await nc_client.deck.get_config()
assert config is not None
logger.info(f"Retrieved deck config: {config}")
async def test_deck_comments_workflow(
nc_client: NextcloudClient, temporary_board_with_card: tuple
):
"""
Test comment operations on a card.
"""
board_data, stack_data, card_data = temporary_board_with_card
card_id = card_data["id"]
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
comment = None
try:
# Create comment
comment = await nc_client.deck.create_comment(card_id, comment_message)
assert comment.message == comment_message
comment_id = comment.id
logger.info(f"Created comment ID: {comment_id}")
# List comments
comments = await nc_client.deck.get_comments(card_id)
assert isinstance(comments, list)
assert any(c.id == comment_id for c in comments)
logger.info(f"Found comment ID: {comment_id} in card comments")
# Update comment
updated_message = f"Updated {comment_message}"
updated_comment = await nc_client.deck.update_comment(
card_id, comment_id, updated_message
)
assert updated_comment.message == updated_message
logger.info(f"Successfully updated comment ID: {comment_id}")
finally:
# Clean up - delete comment
if comment and hasattr(comment, "id"):
try:
await nc_client.deck.delete_comment(card_id, comment.id)
logger.info(f"Cleaned up comment ID: {comment.id}")
except Exception as e:
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")

Some files were not shown because too many files have changed in this diff Show More