Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49f9cead69 | |||
| 415b1c901b | |||
| 90b96a8afe | |||
| 57a2157c58 | |||
| bfdc33c390 | |||
| 8844c07ecb | |||
| 0a0ef10989 | |||
| 9414d9c9c3 | |||
| 8a52df4a8e | |||
| a36038422b | |||
| 2147fc1696 | |||
| a19017c686 | |||
| f0e5333e43 | |||
| 553e84e5f2 | |||
| ff20031601 | |||
| 04e0ab127a | |||
| 1117a83a52 | |||
| 01b43c96ba | |||
| c9db6afb59 | |||
| 50b69a2531 | |||
| 8e0a4d8ce5 | |||
| 72fce189d2 | |||
| 1e877f17f7 | |||
| 50a824155c | |||
| 0df9e41332 | |||
| 13f76a7734 | |||
| 3baf10662f | |||
| 81ca799410 | |||
| 2f1bd1bbe9 | |||
| d452684535 | |||
| bfbaed9a66 | |||
| ff32149220 | |||
| d55e5708c7 | |||
| d4ee5a74c2 | |||
| db79afacb9 | |||
| 261749fcdc | |||
| 6730dd4a4b | |||
| 8734c4b292 | |||
| 29df645d53 | |||
| bdb0e17401 | |||
| 8942f3119c | |||
| 3863cca2ed | |||
| a93e7a1e3b | |||
| f2d2dd8068 | |||
| d915efd3f6 | |||
| 053cf7798b | |||
| 87c6f077f3 | |||
| 38e12db46a | |||
| 1a7ce5b7a7 | |||
| 737780b417 | |||
| b4039e2e40 | |||
| 54e975198f | |||
| e9a16c43b5 | |||
| e48f5f3f30 | |||
| 3ebc468a09 | |||
| 1aecb099e6 | |||
| 2c35e07675 | |||
| 5cfdff0faf | |||
| eb7e15cac0 | |||
| 894723c525 | |||
| 8a3269f366 | |||
| c069d78f80 | |||
| e3436fecc0 | |||
| e3feb3eb2f | |||
| eedaa2e3f1 | |||
| d517fe09d8 | |||
| 98627593d5 | |||
| 64649c902d | |||
| 08ebab9f48 | |||
| f4f9548681 | |||
| 27bb0a4b56 | |||
| 7f5828390c | |||
| 8ad1937347 | |||
| 0d29048155 | |||
| 499429706c | |||
| 2903094d67 | |||
| 7abfa19d15 | |||
| c109626601 | |||
| a5a4e809c4 | |||
| 4984496d81 | |||
| 0e79ba06a9 | |||
| 48744e8a6c | |||
| 63b898c0e3 | |||
| e8f1340133 | |||
| fde68dac55 | |||
| 460e2e190c | |||
| 989b6de3c0 | |||
| aa0b6dc5dd | |||
| 7ae78d3a39 | |||
| 54326f9c64 | |||
| 6ba87e7e05 | |||
| 45bbf97033 | |||
| 14a0f166fe | |||
| 71f09a47ca | |||
| 61bb8cc048 | |||
| ad9b9f25a1 | |||
| f4dd68735c | |||
| c75f0c0a17 | |||
| a143123acc | |||
| 1dc2ddfdb7 | |||
| 92e18825bc | |||
| d398a8c8e6 | |||
| 39dfa13895 | |||
| cb7a609ec2 | |||
| b8d241b596 | |||
| 5395f8d3d6 | |||
| 198d7495f0 | |||
| c2f6c6ce0d | |||
| 5757f2582b | |||
| d5e6411c45 | |||
| f0c03ceede | |||
| 7818eb104e | |||
| b72514bb32 | |||
| f51d3a2101 | |||
| 5de4055f9f | |||
| 95da43ea0f | |||
| ae47c5f3e6 | |||
| 31ffeba69b | |||
| 963a504ae2 | |||
| ead298c132 | |||
| 2f805e54b7 | |||
| 6158a890af | |||
| 240ceb3808 | |||
| 1459fe9bc8 | |||
| 37164dbdbc | |||
| c3ff92a8c1 | |||
| 371d0c93a5 | |||
| 644c59bf78 | |||
| 056b6fc9d6 | |||
| 83917b3786 | |||
| 955ad78f13 | |||
| 3f04449a86 | |||
| 144a54c1ad | |||
| 90b4b2a038 | |||
| cdfab26c75 | |||
| a389f2940e | |||
| 5e829fc7e7 | |||
| 9c909b6e42 | |||
| 9b29eabfaa | |||
| 7549c988f4 | |||
| 0145be4bbd | |||
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e | |||
| 038fcddd48 | |||
| 394b27ee4a | |||
| 9de59db718 | |||
| 6734de8389 | |||
| 3cb31d07f1 | |||
| 16b9123af3 | |||
| 51d1f075f5 | |||
| e0a68d47a5 | |||
| 832cb51dd3 | |||
| f6256c10db | |||
| 7b2002c1b5 | |||
| d150cf2e72 | |||
| 3921d9b982 | |||
| 9e4c20a4b1 | |||
| f26bca13f1 | |||
| 46c6f2f294 | |||
| 3ad9198f36 | |||
| dafac734e6 | |||
| 97bbc18121 | |||
| 46deb0f726 | |||
| daacf08a54 | |||
| cc2a5c9d58 | |||
| 26f8deff17 | |||
| fb3063e94e | |||
| 83f89e9394 | |||
| 5db02313a1 | |||
| b50e212f05 | |||
| 85f8522085 | |||
| a38c795124 | |||
| 7004104873 | |||
| 7a4a31b52d | |||
| 898c2e72ae | |||
| 8652684466 | |||
| 72ace9da9e | |||
| ab40127811 | |||
| 3ff6346c03 | |||
| c9a687171a | |||
| df5f85e0c6 | |||
| 76dce41ed9 | |||
| 642108ee91 | |||
| ce5724f05e | |||
| 961f23b5ea |
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -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@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -25,6 +25,25 @@ 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@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 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
|
||||
@@ -33,11 +52,11 @@ jobs:
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install firefox --with-deps
|
||||
uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -62,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --browser firefox
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
|
||||
+3
-1
@@ -4,4 +4,6 @@ __pycache__/
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.nextcloud_oauth_test_client.json
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_*.json
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[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
|
||||
+134
@@ -1,3 +1,137 @@
|
||||
## v0.21.0 (2025-10-25)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add text processing background worker for telling client about progress
|
||||
|
||||
### Refactor
|
||||
|
||||
- Transform document parsing into pluggable processor architecture
|
||||
|
||||
## v0.20.0 (2025-10-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: Add support for client registration deletion
|
||||
- Split read/write scopes into app:read/write scopes
|
||||
|
||||
### Fix
|
||||
|
||||
- Add support for RFC 7592 client registration and deletion
|
||||
- Update webdav models for proper serialization
|
||||
|
||||
## v0.19.1 (2025-10-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||
|
||||
## v0.19.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Enable token introspection for opaque tokens
|
||||
|
||||
### Fix
|
||||
|
||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
|
||||
## 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
|
||||
|
||||
@@ -5,20 +5,88 @@ 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
|
||||
# Run all tests
|
||||
# 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
|
||||
uv run pytest
|
||||
|
||||
# Run integration tests only
|
||||
uv run pytest -m integration
|
||||
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||
uv run pytest -m oauth -v
|
||||
|
||||
# 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"
|
||||
uv run pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet.
|
||||
|
||||
### 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
|
||||
@@ -38,13 +106,23 @@ mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart only the MCP server container
|
||||
# After code changes, rebuild and restart the appropriate MCP server container:
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
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
|
||||
@@ -54,6 +132,36 @@ 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:
|
||||
@@ -81,7 +189,17 @@ 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
|
||||
- **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
|
||||
- **Contacts** - CardDAV integration with address book operations
|
||||
- **Tables** - Row-level operations on Nextcloud Tables
|
||||
- **WebDAV** - Complete file system access
|
||||
@@ -94,72 +212,187 @@ 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
|
||||
|
||||
- **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
|
||||
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`
|
||||
|
||||
#### 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
|
||||
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
|
||||
- **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 existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing
|
||||
- `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_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/integration/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
|
||||
- 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)
|
||||
- **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 support both **automated** (Playwright) and **interactive** authentication flows:
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**Automated Testing (Default - Recommended for CI/CD):**
|
||||
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default
|
||||
- Uses Playwright headless browser automation to complete OAuth flow programmatically
|
||||
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
|
||||
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
**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:
|
||||
```bash
|
||||
# Run all OAuth tests with automated Playwright flow using Firefox
|
||||
uv run pytest tests/integration/test_oauth*.py --browser firefox -v
|
||||
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
|
||||
# Run specific Playwright tests with visible browser for debugging
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/integration/test_oauth.py -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
|
||||
|
||||
**Interactive Testing (Manual browser login):**
|
||||
- Opens system browser and waits for manual login/authorization
|
||||
- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive`
|
||||
- Requires: User to complete browser-based login when prompted
|
||||
- Useful for: Debugging OAuth flows, testing with 2FA, local development
|
||||
- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable
|
||||
- Example:
|
||||
```bash
|
||||
# Run OAuth tests with interactive flow (will open browser and wait for manual login)
|
||||
uv run pytest tests/integration/test_oauth_interactive.py -v
|
||||
```
|
||||
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||
uv run pytest -m oauth -v
|
||||
```
|
||||
|
||||
**Test Environment Setup:**
|
||||
**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`
|
||||
- OAuth server runs on port 8001 (regular MCP on 8000)
|
||||
- Both flows register OAuth clients dynamically using Nextcloud's OIDC provider
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
|
||||
**CI/CD Considerations:**
|
||||
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
|
||||
- Automated Playwright tests will run in CI/CD environments
|
||||
**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
|
||||
@@ -167,3 +400,15 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv
|
||||
- **`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
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,742 +0,0 @@
|
||||
# OAuth2/OIDC Implementation Plan for Nextcloud MCP Server
|
||||
|
||||
## Executive Summary
|
||||
Upgrade the Nextcloud MCP server to support OAuth2/OIDC authentication using Nextcloud's OIDC app as the Authorization Server, eliminating the need for baked-in credentials in server deployment.
|
||||
|
||||
**Status**: ✅ Research Complete - Implementation Ready
|
||||
|
||||
## Research Findings Summary
|
||||
|
||||
### ✅ Verified Nextcloud OIDC Capabilities
|
||||
- **Token Format**: Opaque tokens by default, **RFC 9068 JWT access tokens available** (must be enabled per-client)
|
||||
- **Discovery**: Full OpenID Connect discovery available at `/.well-known/openid-configuration`
|
||||
- **JWKS**: Available at `/apps/oidc/jwks` for JWT signature validation
|
||||
- **Dynamic Registration**: Supported via `/apps/oidc/register` (must be enabled by admin)
|
||||
- **Introspection**: ❌ NOT available - must use **userinfo endpoint** for token validation
|
||||
- **Userinfo**: Available at `/apps/oidc/userinfo` - validates token and returns user claims
|
||||
- **Scopes**: `openid`, `profile`, `email`, `roles`, `groups`
|
||||
- **User Claims**: `sub`, `preferred_username` (both contain Nextcloud username)
|
||||
|
||||
### 🔑 Key Implementation Decisions
|
||||
1. **Primary Token Validation**: Use **userinfo endpoint** (not introspection)
|
||||
2. **JWT Support**: Optional - enables local validation if client configured for RFC 9068
|
||||
3. **User Context**: Extract username from `sub` or `preferred_username` claim via userinfo
|
||||
4. **Dynamic Registration**: Primary deployment method (zero-config)
|
||||
5. **Token Lifetime**: Access tokens default to 3600s, clients default to 3600s (both configurable)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Server Role: Resource Server (RS) - RFC 9728
|
||||
The MCP server acts as a **Resource Server** that:
|
||||
- Validates OAuth tokens issued by Nextcloud OIDC app (Authorization Server)
|
||||
- Protects MCP tools/resources with OAuth authentication
|
||||
- Uses validated tokens to make Nextcloud API calls on behalf of authenticated users
|
||||
|
||||
### Authentication Flow
|
||||
```
|
||||
1. Client connects to MCP Server (RS)
|
||||
2. MCP Server provides RFC 9728 metadata pointing to Nextcloud OIDC (AS)
|
||||
3. Client performs OAuth flow with Nextcloud OIDC
|
||||
4. Client presents access token to MCP Server
|
||||
5. MCP Server validates token via userinfo endpoint (or JWT if configured)
|
||||
6. MCP Server extracts username from claims
|
||||
7. MCP Server uses token to call Nextcloud APIs with user context
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Dynamic Client Registration (PRIMARY APPROACH)
|
||||
**Use Nextcloud OIDC's Dynamic Client Registration for zero-config deployment**
|
||||
|
||||
**Benefits:**
|
||||
- No manual client setup required
|
||||
- MCP server auto-registers on first startup
|
||||
- Automatic credential generation
|
||||
- Self-healing if client expires
|
||||
- Better developer/deployment experience
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Startup sequence:
|
||||
1. Check for existing client credentials (file/env)
|
||||
2. If none found, POST to /apps/oidc/register
|
||||
3. Store client_id and client_secret persistently
|
||||
4. Use credentials for OAuth flow
|
||||
5. Auto re-register if client expires (3600s default)
|
||||
```
|
||||
|
||||
**Nextcloud OIDC Requirements:**
|
||||
- Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||
- Rate limiting via BruteForce protection
|
||||
- Max 100 dynamic clients per instance
|
||||
- Clients expire after 1 hour (configurable via occ)
|
||||
|
||||
### 2. Token Validation Strategy: Userinfo Endpoint (PRIMARY)
|
||||
|
||||
**✅ VERIFIED IMPLEMENTATION: Userinfo Endpoint Validation**
|
||||
|
||||
Nextcloud OIDC **does NOT provide** a token introspection endpoint. Token validation must use:
|
||||
|
||||
**Primary: Userinfo Endpoint Validation**
|
||||
- Call `/apps/oidc/userinfo` with Bearer token
|
||||
- Nextcloud validates token internally (checks expiration, client, etc.)
|
||||
- Returns user claims if valid: `sub`, `preferred_username`, `email`, `roles`, `groups`
|
||||
- HTTP 400/401 if token invalid
|
||||
- Cache results with TTL matching token expiration (3600s default)
|
||||
|
||||
**Implementation Pattern**:
|
||||
```python
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
# Call userinfo endpoint
|
||||
response = await client.get(
|
||||
f"{nextcloud_host}/apps/oidc/userinfo",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
claims = response.json()
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=["openid", "profile"], # From original request
|
||||
expires_at=calculate_expiry() # 3600s from now
|
||||
)
|
||||
return None # Invalid token
|
||||
```
|
||||
|
||||
**Optional: JWT Validation (Performance Optimization)**
|
||||
- Available if client configured with "JWT Access Tokens (RFC 9068)" enabled
|
||||
- Fetch JWKS from `/apps/oidc/jwks`
|
||||
- Validate JWT signatures locally (no network call)
|
||||
- Cache JWKS with refresh mechanism
|
||||
- Falls back to userinfo if JWT validation fails
|
||||
|
||||
**Trade-offs**:
|
||||
- Userinfo: Simpler, always works, network call per validation
|
||||
- JWT: Faster, no network call, requires per-client configuration
|
||||
|
||||
### 3. Dual-Mode Authentication (Backward Compatibility)
|
||||
Support both authentication modes:
|
||||
|
||||
**Mode 1: OAuth2/OIDC (NEW)**
|
||||
- Environment: `NEXTCLOUD_HOST` + optional `NEXTCLOUD_OIDC_CLIENT_ID/SECRET`
|
||||
- Auto-registers if no client credentials provided
|
||||
- Per-request client creation with bearer token
|
||||
|
||||
**Mode 2: Basic Auth (LEGACY)**
|
||||
- Environment: `NEXTCLOUD_HOST` + `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD`
|
||||
- Current implementation preserved
|
||||
- Single client in lifespan context
|
||||
|
||||
### 4. HTTP Client Architecture
|
||||
|
||||
**✅ REVISED: Context-aware Client Retrieval**
|
||||
|
||||
Instead of per-request client creation, use a helper that extracts user context:
|
||||
|
||||
```python
|
||||
# Helper function to get client from MCP context
|
||||
async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
"""Extract authenticated user context and create NextcloudClient."""
|
||||
# MCP SDK provides AccessToken from TokenVerifier
|
||||
access_token: AccessToken = ctx.request_context.session.access_token
|
||||
|
||||
# Extract username from cached userinfo claims
|
||||
# (stored during token verification)
|
||||
username = access_token.scopes[0] # Or from custom metadata
|
||||
|
||||
# Create client with bearer token
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url,
|
||||
token=access_token.token,
|
||||
username=username
|
||||
)
|
||||
|
||||
# In tool implementations:
|
||||
@mcp.tool()
|
||||
async def nc_notes_create(title: str, content: str):
|
||||
ctx = mcp.get_context()
|
||||
|
||||
if oauth_mode:
|
||||
client = await get_client_from_context(ctx, nextcloud_host)
|
||||
else:
|
||||
# Legacy: use lifespan client
|
||||
client = ctx.request_context.lifespan_context.client
|
||||
|
||||
return await client.notes.create_note(title, content)
|
||||
```
|
||||
|
||||
**Key Pattern**:
|
||||
- Token verification caches userinfo claims
|
||||
- Helper retrieves username from cached data (no additional API call)
|
||||
- Client uses bearer token for Nextcloud API calls
|
||||
|
||||
### 5. User Context Extraction
|
||||
|
||||
**✅ VERIFIED: Userinfo Endpoint Response**
|
||||
|
||||
From Nextcloud OIDC userinfo endpoint response:
|
||||
- **Username**: `sub` AND `preferred_username` (both contain Nextcloud username)
|
||||
- **Scopes**: Determined by scopes requested during OAuth flow
|
||||
- **Groups/Roles**: Available via `roles` or `groups` scope
|
||||
- **Profile**: `name`, `email`, `picture`, etc. (if `profile` scope requested)
|
||||
|
||||
**Implementation**:
|
||||
```python
|
||||
# During token verification:
|
||||
userinfo = await fetch_userinfo(token)
|
||||
# {
|
||||
# "sub": "username",
|
||||
# "preferred_username": "username",
|
||||
# "email": "user@example.com",
|
||||
# "roles": ["group1", "group2"], # if 'roles' scope
|
||||
# "groups": ["group1", "group2"] # if 'groups' scope
|
||||
# }
|
||||
|
||||
username = userinfo["sub"] # or userinfo["preferred_username"]
|
||||
```
|
||||
|
||||
**Storage Strategy**:
|
||||
- Cache userinfo in AccessToken metadata
|
||||
- Use MCP SDK's built-in token caching
|
||||
- TTL matches access token expiration (3600s default)
|
||||
|
||||
## Implementation Components
|
||||
|
||||
### New Modules
|
||||
|
||||
#### 1. `nextcloud_mcp_server/auth/__init__.py`
|
||||
Exports: `NextcloudTokenVerifier`, `BearerAuth`, `register_client`
|
||||
|
||||
#### 2. `nextcloud_mcp_server/auth/token_verifier.py`
|
||||
```python
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||
|
||||
Primary method: Userinfo endpoint validation (always works)
|
||||
Optional: JWT validation if client configured for RFC 9068
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
enable_jwt_validation: bool = False
|
||||
):
|
||||
self.nextcloud_host = nextcloud_host
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.enable_jwt_validation = enable_jwt_validation
|
||||
|
||||
# Cache for validated tokens: token -> (userinfo, expiry)
|
||||
self._token_cache: dict[str, tuple[dict, float]] = {}
|
||||
|
||||
# JWKS cache (if JWT validation enabled)
|
||||
self._jwks: dict | None = None
|
||||
self._jwks_expires: float = 0
|
||||
|
||||
self._client = httpx.AsyncClient()
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify token using userinfo endpoint (primary) or JWT validation (optional).
|
||||
|
||||
Returns AccessToken with userinfo cached in metadata.
|
||||
"""
|
||||
# Check cache first
|
||||
if token in self._token_cache:
|
||||
userinfo, expiry = self._token_cache[token]
|
||||
if time.time() < expiry:
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
# Try JWT validation first if enabled
|
||||
if self.enable_jwt_validation and self.jwks_uri:
|
||||
access_token = await self._verify_jwt(token)
|
||||
if access_token:
|
||||
return access_token
|
||||
|
||||
# Fall back to (or use primary) userinfo validation
|
||||
return await self._verify_via_userinfo(token)
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""Validate token by calling userinfo endpoint."""
|
||||
try:
|
||||
response = await self._client.get(
|
||||
self.userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
userinfo = response.json()
|
||||
|
||||
# Cache for 3600s (default token lifetime)
|
||||
# TODO: Get actual expiry from token if JWT
|
||||
expiry = time.time() + 3600
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Userinfo validation failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
async def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""Validate JWT token locally using JWKS (optional optimization)."""
|
||||
try:
|
||||
# Fetch JWKS if not cached
|
||||
if not self._jwks or time.time() > self._jwks_expires:
|
||||
await self._refresh_jwks()
|
||||
|
||||
# Decode and validate JWT
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
self._jwks,
|
||||
algorithms=["RS256", "HS256"],
|
||||
issuer=self.nextcloud_host,
|
||||
options={"verify_aud": False} # Nextcloud may not include aud
|
||||
)
|
||||
|
||||
# Extract userinfo from JWT claims
|
||||
userinfo = {
|
||||
"sub": claims.get("sub"),
|
||||
"preferred_username": claims.get("preferred_username"),
|
||||
"email": claims.get("email"),
|
||||
"roles": claims.get("roles", []),
|
||||
"groups": claims.get("groups", [])
|
||||
}
|
||||
|
||||
# Cache
|
||||
expiry = claims.get("exp", time.time() + 3600)
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
return self._create_access_token(token, userinfo)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"JWT validation failed, falling back to userinfo: {e}")
|
||||
return None
|
||||
|
||||
def _create_access_token(self, token: str, userinfo: dict) -> AccessToken:
|
||||
"""Create AccessToken with userinfo in metadata."""
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=["openid", "profile", "email"], # TODO: Track actual scopes
|
||||
expires_at=int(time.time() + 3600), # TODO: Get from JWT exp claim
|
||||
# Store username in scopes[0] as workaround for MCP SDK limitation
|
||||
# Or use custom AccessToken subclass with username field
|
||||
)
|
||||
|
||||
async def _refresh_jwks(self):
|
||||
"""Fetch JWKS from Nextcloud OIDC."""
|
||||
response = await self._client.get(self.jwks_uri)
|
||||
response.raise_for_status()
|
||||
self._jwks = response.json()
|
||||
self._jwks_expires = time.time() + 3600 # Cache for 1 hour
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
await self._client.aclose()
|
||||
```
|
||||
|
||||
#### 3. `nextcloud_mcp_server/auth/client_registration.py`
|
||||
```python
|
||||
async def register_client(
|
||||
nextcloud_url: str,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] = None
|
||||
) -> dict:
|
||||
"""Register MCP server as OAuth client with Nextcloud OIDC"""
|
||||
# POST to /apps/oidc/register
|
||||
# Return client_id, client_secret, expires_at
|
||||
|
||||
async def load_or_register_client(storage_path: str) -> dict:
|
||||
"""Load existing client or register new one"""
|
||||
# Check storage file
|
||||
# Validate expiration
|
||||
# Re-register if expired
|
||||
# Persist credentials
|
||||
```
|
||||
|
||||
#### 4. `nextcloud_mcp_server/auth/bearer_auth.py`
|
||||
```python
|
||||
class BearerAuth(httpx.Auth):
|
||||
"""Bearer token authentication for httpx"""
|
||||
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request):
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
#### 1. `nextcloud_mcp_server/app.py`
|
||||
```python
|
||||
# Add OAuth configuration
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, register_client
|
||||
|
||||
# In get_app():
|
||||
if oauth_enabled:
|
||||
# Load or register client
|
||||
client_info = await load_or_register_client(storage_path)
|
||||
|
||||
# Create token verifier
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
jwks_uri=f"{nextcloud_host}/apps/oidc/jwks",
|
||||
issuer=f"{nextcloud_host}"
|
||||
)
|
||||
|
||||
# Configure FastMCP with OAuth
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
token_verifier=token_verifier,
|
||||
auth=AuthSettings(
|
||||
issuer_url=nextcloud_host,
|
||||
resource_server_url=mcp_server_url,
|
||||
required_scopes=["openid", "profile"]
|
||||
),
|
||||
lifespan=app_lifespan_oauth # Don't create client in lifespan
|
||||
)
|
||||
else:
|
||||
# Legacy BasicAuth mode
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
```
|
||||
|
||||
#### 2. `nextcloud_mcp_server/client/__init__.py`
|
||||
```python
|
||||
class NextcloudClient:
|
||||
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||
# Accept either BasicAuth or BearerAuth
|
||||
self._client = AsyncClient(base_url=base_url, auth=auth, ...)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
"""Legacy: Create from username/password env vars"""
|
||||
return cls(base_url, username, auth=BasicAuth(username, password))
|
||||
|
||||
@classmethod
|
||||
def from_token(cls, base_url: str, token: str, username: str):
|
||||
"""OAuth: Create from bearer token"""
|
||||
return cls(base_url, username, auth=BearerAuth(token))
|
||||
```
|
||||
|
||||
#### 3. `nextcloud_mcp_server/server/notes.py` (and other tool modules)
|
||||
```python
|
||||
from nextcloud_mcp_server.auth import get_client_from_context
|
||||
|
||||
@mcp.tool()
|
||||
async def nc_notes_create(title: str, content: str):
|
||||
ctx: Context = mcp.get_context()
|
||||
|
||||
# OAuth mode: Get client from request context
|
||||
if oauth_enabled:
|
||||
client = get_client_from_context(ctx)
|
||||
else:
|
||||
# Legacy mode: Use lifespan client
|
||||
client = ctx.request_context.lifespan_context.client
|
||||
|
||||
return await client.notes.create_note(...)
|
||||
```
|
||||
|
||||
#### 4. `nextcloud_mcp_server/config.py`
|
||||
```python
|
||||
class NextcloudConfig:
|
||||
# Common
|
||||
host: str
|
||||
|
||||
# OAuth mode
|
||||
oauth_enabled: bool = False
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
client_storage_path: str = ".nextcloud_oauth_client.json"
|
||||
mcp_server_url: str = "http://localhost:8000/mcp"
|
||||
required_scopes: list[str] = ["openid", "profile", "email"]
|
||||
|
||||
# Legacy mode
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_env(cls):
|
||||
oauth_enabled = not (
|
||||
os.getenv("NEXTCLOUD_USERNAME") and
|
||||
os.getenv("NEXTCLOUD_PASSWORD")
|
||||
)
|
||||
return cls(oauth_enabled=oauth_enabled, ...)
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
#### Updated `env.sample`
|
||||
```bash
|
||||
# Nextcloud Instance
|
||||
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
|
||||
# ===== AUTHENTICATION MODE =====
|
||||
# Choose ONE of the following:
|
||||
|
||||
# Option 1: OAuth2/OIDC (RECOMMENDED)
|
||||
# - Requires Nextcloud OIDC app installed
|
||||
# - Enable "Dynamic Client Registration" in OIDC app settings
|
||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty
|
||||
# - Optional: Pre-register client and provide credentials
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000/mcp
|
||||
|
||||
# Option 2: Basic Authentication (LEGACY - Will be deprecated)
|
||||
# - Requires username and password
|
||||
# - Less secure - credentials stored in environment
|
||||
# - Use only for backward compatibility
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
### New Python Dependencies
|
||||
```toml
|
||||
# pyproject.toml additions:
|
||||
dependencies = [
|
||||
# ... existing ...
|
||||
"PyJWT[crypto]>=2.8.0", # JWT validation
|
||||
"cryptography>=41.0.0", # JWKS key handling (if not present)
|
||||
]
|
||||
```
|
||||
|
||||
## Nextcloud OIDC Setup
|
||||
|
||||
### Administrator Setup (One-time)
|
||||
1. Install Nextcloud OIDC app from App Store
|
||||
2. Navigate to Settings → OIDC
|
||||
3. Enable "Dynamic Client Registration"
|
||||
4. (Optional) Configure token expiration times via CLI:
|
||||
```bash
|
||||
php occ config:app:set oidc expire_time --value "3600"
|
||||
php occ config:app:set oidc refresh_expire_time --value "86400"
|
||||
```
|
||||
|
||||
### MCP Server Deployment (Zero-config)
|
||||
1. Set `NEXTCLOUD_HOST` environment variable
|
||||
2. Set `NEXTCLOUD_MCP_SERVER_URL` (if not localhost:8000)
|
||||
3. Start MCP server → Auto-registers on first run
|
||||
4. Client credentials stored in `.nextcloud_oauth_client.json`
|
||||
|
||||
### Alternative: Pre-registered Client
|
||||
```bash
|
||||
# Create client via CLI
|
||||
php occ oidc:create \
|
||||
--name="Nextcloud MCP Server" \
|
||||
--type=confidential \
|
||||
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||
|
||||
# Set credentials in environment
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<generated-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<generated-secret>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Token validation with mocked JWKS
|
||||
- JWT claim extraction
|
||||
- Client registration flow
|
||||
- Bearer auth implementation
|
||||
|
||||
### Integration Tests
|
||||
- Dynamic client registration against test Nextcloud
|
||||
- OAuth flow end-to-end
|
||||
- Token-based API calls
|
||||
- Client expiration and re-registration
|
||||
- Dual-mode authentication (OAuth + BasicAuth)
|
||||
|
||||
### Test Fixtures
|
||||
```python
|
||||
# tests/conftest.py additions:
|
||||
@pytest.fixture
|
||||
def mock_oidc_server():
|
||||
"""Mock Nextcloud OIDC endpoints"""
|
||||
# Mock /apps/oidc/openid-configuration
|
||||
# Mock /apps/oidc/jwks
|
||||
# Mock /apps/oidc/register
|
||||
# Mock /apps/oidc/token
|
||||
|
||||
@pytest.fixture
|
||||
async def oauth_nc_client(mock_oidc_server):
|
||||
"""NextcloudClient with OAuth token"""
|
||||
token = generate_test_jwt()
|
||||
return NextcloudClient.from_token(base_url, token, "testuser")
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Implementation (Week 1-2)
|
||||
- [ ] Implement token verifier with JWT validation
|
||||
- [ ] Implement dynamic client registration
|
||||
- [ ] Add BearerAuth for httpx
|
||||
- [ ] Modify NextcloudClient for dual-mode auth
|
||||
- [ ] Update app.py with OAuth configuration
|
||||
- [ ] Add configuration management
|
||||
|
||||
### Phase 2: Testing (Week 2-3)
|
||||
- [ ] Unit tests for all auth components
|
||||
- [ ] Integration tests with test Nextcloud instance
|
||||
- [ ] End-to-end OAuth flow testing
|
||||
- [ ] Backward compatibility testing
|
||||
|
||||
### Phase 3: Documentation (Week 3)
|
||||
- [ ] Update README.md with OAuth setup
|
||||
- [ ] Update CLAUDE.md with architecture changes
|
||||
- [ ] Add OAuth troubleshooting guide
|
||||
- [ ] Document OIDC app configuration
|
||||
- [ ] Add migration guide for existing deployments
|
||||
|
||||
### Phase 4: Deployment (Week 4)
|
||||
- [ ] Release with both modes supported
|
||||
- [ ] Monitor for issues
|
||||
- [ ] Deprecation notice for BasicAuth
|
||||
- [ ] Plan BasicAuth removal timeline (6+ months)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Token Security
|
||||
- Store client secrets securely (file permissions, secret managers)
|
||||
- Validate JWT signatures against trusted JWKS
|
||||
- Verify token claims (issuer, audience, expiration)
|
||||
- Implement token refresh logic
|
||||
- Rate limit token validation failures
|
||||
|
||||
### Client Registration Security
|
||||
- Nextcloud OIDC provides BruteForce protection
|
||||
- Dynamic clients limited to 100 per instance
|
||||
- Clients expire after 1 hour (configurable)
|
||||
- Admin must explicitly enable dynamic registration
|
||||
|
||||
### API Security
|
||||
- Bearer tokens used for Nextcloud API calls
|
||||
- Token scopes control access levels
|
||||
- User context preserved in all API operations
|
||||
- No credential storage in MCP server
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### JWT Validation Performance
|
||||
- JWKS caching with TTL (e.g., 1 hour)
|
||||
- Key rotation handling via JWKS refresh
|
||||
- Local validation (no network call per request)
|
||||
- Async validation to avoid blocking
|
||||
|
||||
### Client Creation
|
||||
- OAuth mode: Per-request client creation (lightweight)
|
||||
- BasicAuth mode: Single client in lifespan (current)
|
||||
- Connection pooling maintained in both modes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Scope-based Authorization
|
||||
- Define custom Nextcloud scopes for MCP operations
|
||||
- Map MCP tools to required scopes
|
||||
- Fine-grained permission control
|
||||
|
||||
### Multi-tenant Support
|
||||
- Support multiple Nextcloud instances
|
||||
- Per-user client registration
|
||||
- Tenant isolation
|
||||
|
||||
### Token Introspection Fallback
|
||||
- Implement RFC 7662 introspection
|
||||
- Use if JWT validation fails
|
||||
- Support for opaque tokens
|
||||
|
||||
### Admin Controls
|
||||
- MCP server admin UI for OAuth config
|
||||
- Client credential rotation
|
||||
- Usage monitoring and logging
|
||||
|
||||
## Decisions Made (Post-Research)
|
||||
|
||||
1. **✅ Token Validation Method**: Userinfo endpoint (primary), JWT optional
|
||||
- Nextcloud OIDC does NOT provide introspection endpoint
|
||||
- Userinfo endpoint validates token AND returns user claims
|
||||
- JWT validation available as performance optimization if client configured
|
||||
|
||||
2. **✅ Client expiration handling**: Auto re-register with logging
|
||||
- Clients expire after 3600s by default
|
||||
- Check expiry on startup and periodically
|
||||
- Auto-register with backoff on failure
|
||||
|
||||
3. **✅ Scope requirements**: `["openid", "profile", "email"]`
|
||||
- Sufficient for basic user identification
|
||||
- Optional: Add `"roles"` or `"groups"` for group-based authorization
|
||||
|
||||
4. **✅ Token caching**: In-memory with 3600s TTL
|
||||
- Cache userinfo response (includes all needed claims)
|
||||
- Use token string as cache key
|
||||
- TTL matches default access token lifetime
|
||||
|
||||
5. **✅ Client storage**: JSON file with 0600 permissions
|
||||
- Default: `.nextcloud_oauth_client.json`
|
||||
- Configurable via env var
|
||||
- Contains: client_id, client_secret, issued_at
|
||||
|
||||
6. **✅ Username extraction**: From `sub` or `preferred_username` claim
|
||||
- Both contain Nextcloud username (verified)
|
||||
- Retrieved during token validation
|
||||
- Cached with token
|
||||
|
||||
7. **✅ BasicAuth deprecation**: 12 months after OAuth stable release
|
||||
- Phase 1: OAuth + BasicAuth (6 months)
|
||||
- Phase 2: OAuth only, deprecation warnings (6 months)
|
||||
- Phase 3: Remove BasicAuth
|
||||
|
||||
## Key Changes from Original Plan
|
||||
|
||||
### 1. Token Validation
|
||||
**Original**: JWT validation with JWKS (primary), introspection (fallback)
|
||||
**Updated**: Userinfo endpoint (primary), JWT validation (optional optimization)
|
||||
- Reason: Nextcloud OIDC has no introspection endpoint
|
||||
|
||||
### 2. User Context Extraction
|
||||
**Original**: Extract username from JWT claims
|
||||
**Updated**: Fetch from userinfo endpoint during validation
|
||||
- Reason: Opaque tokens by default, userinfo always works
|
||||
|
||||
### 3. Token Caching Strategy
|
||||
**Original**: MCP SDK handles all caching
|
||||
**Updated**: Custom cache in TokenVerifier for userinfo responses
|
||||
- Reason: Need to cache username separately from AccessToken
|
||||
|
||||
### 4. JWT Support
|
||||
**Original**: Required for all deployments
|
||||
**Updated**: Optional performance optimization
|
||||
- Reason: Requires per-client configuration in Nextcloud OIDC
|
||||
- Default: Opaque tokens validated via userinfo
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Python SDK OAuth Documentation](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [MCP RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||
- [Nextcloud OIDC App Repository](https://github.com/H2CK/oidc)
|
||||
- [OpenID Connect Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html)
|
||||
- [RFC 9068 JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||
- [MCP Simple Auth Example](~/Software/python-sdk/examples/servers/simple-auth/)
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ MCP server can authenticate via Nextcloud OIDC with zero manual client setup
|
||||
✅ Dynamic client registration works automatically on first run
|
||||
✅ JWT tokens validated locally without per-request network calls
|
||||
✅ Backward compatibility maintained with BasicAuth mode
|
||||
✅ All existing tests pass in both auth modes
|
||||
✅ Documentation complete for OAuth setup and migration
|
||||
✅ Security review passed (token handling, credential storage)
|
||||
✅ Performance benchmarks meet targets (< 10ms token validation overhead)
|
||||
@@ -1,121 +0,0 @@
|
||||
# OAuth Testing Setup
|
||||
|
||||
This document describes the automated OAuth testing infrastructure for the Nextcloud MCP server.
|
||||
|
||||
## Overview
|
||||
|
||||
We've created a comprehensive testing setup that includes:
|
||||
|
||||
1. **OIDC App Configuration** - Nextcloud OIDC app automatically installed and configured with dynamic client registration
|
||||
2. **Dual MCP Services** - Two MCP server instances running in Docker:
|
||||
- `mcp` (port 8000) - BasicAuth mode (username/password)
|
||||
- `mcp-oauth` (port 8001) - OAuth mode (dynamic client registration)
|
||||
3. **Test Fixtures** - Pytest fixtures for OAuth client testing
|
||||
4. **Integration Tests** - OAuth-specific integration tests
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
The `docker-compose.yml` includes:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app: # Nextcloud with OIDC app enabled
|
||||
mcp: # BasicAuth MCP server (port 8000)
|
||||
mcp-oauth: # OAuth MCP server (port 8001)
|
||||
```
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
The OIDC app is configured automatically via `app-hooks/post-installation/install-oidc-app.sh`:
|
||||
|
||||
- **Dynamic Client Registration**: Enabled
|
||||
- **Config Key**: `dynamic_client_registration` (not `allow_dynamic_client_registration`)
|
||||
- **Registration Endpoint**: `http://localhost:8080/apps/oidc/register`
|
||||
|
||||
### Important: Config Key Fix
|
||||
|
||||
The correct OIDC config key is `dynamic_client_registration`. The initial implementation used `allow_dynamic_client_registration` which was incorrect and caused the registration endpoint to not appear in the OIDC discovery document.
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Located in `tests/conftest.py`:
|
||||
|
||||
### `oauth_token`
|
||||
Session-scoped fixture that obtains an OAuth access token.
|
||||
|
||||
**Current Limitation**: Nextcloud OIDC only supports `authorization_code` and `refresh_token` grant types, not the `password` grant type. This means we cannot automatically obtain tokens for testing without implementing a full browser-based OAuth flow.
|
||||
|
||||
### `nc_oauth_client`
|
||||
Session-scoped NextcloudClient configured with OAuth bearer token authentication.
|
||||
|
||||
**Status**: Implemented but currently skipped due to token acquisition limitation.
|
||||
|
||||
### `nc_mcp_oauth_client`
|
||||
Session-scoped MCP client that connects to the OAuth-enabled MCP server on port 8001.
|
||||
|
||||
**Status**: Implemented but marked as skip - requires full OAuth authorization flow implementation in MCP SDK.
|
||||
|
||||
## Current Test Status
|
||||
|
||||
### ✅ Working
|
||||
- OIDC app installation and configuration
|
||||
- Dynamic client registration
|
||||
- OAuth infrastructure (BearerAuth, TokenVerifier, client registration)
|
||||
- Docker compose dual-mode setup
|
||||
|
||||
### ⚠️ Limitations
|
||||
- **No automated token acquisition**: Nextcloud OIDC doesn't support the Resource Owner Password Credentials grant, which means we cannot programmatically get tokens for testing without browser interaction
|
||||
- **Manual testing only**: OAuth functionality must be tested manually using a browser-based OAuth flow
|
||||
- **MCP OAuth server untested**: The OAuth MCP server requires the full OAuth authorization flow to be implemented in the MCP Python SDK
|
||||
|
||||
## Manual Testing OAuth
|
||||
|
||||
To manually test OAuth functionality:
|
||||
|
||||
1. Start the docker-compose environment:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. The OAuth MCP server runs on port 8001 and will:
|
||||
- Automatically register a client via dynamic registration
|
||||
- Store client credentials in `/app/.oauth/` volume
|
||||
- Display OAuth configuration on startup
|
||||
|
||||
3. To test OAuth with a real client:
|
||||
- Use the authorization endpoint: `http://localhost:8080/apps/oidc/authorize`
|
||||
- Implement the authorization code flow
|
||||
- Exchange code for token at: `http://localhost:8080/apps/oidc/token`
|
||||
|
||||
## Future Work
|
||||
|
||||
To enable automated OAuth testing, one of these approaches is needed:
|
||||
|
||||
1. **Mock OIDC Server**: Create a test OIDC server that supports password grant
|
||||
2. **Browser Automation**: Use Selenium/Playwright to automate the OAuth flow
|
||||
3. **Test-Only Password Grant**: Patch Nextcloud OIDC to support password grant in test mode
|
||||
4. **Pre-generated Tokens**: Manually generate long-lived tokens and use them in tests
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (OAuth tests will be skipped)
|
||||
uv run pytest tests/integration/test_oauth.py -v
|
||||
|
||||
# Run only the invalid token test (this one works)
|
||||
uv run pytest tests/integration/test_oauth.py::TestOAuthTokenValidation::test_invalid_token_fails -v
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `tests/conftest.py` - Added OAuth fixtures and token acquisition logic
|
||||
- `tests/integration/test_oauth.py` - OAuth-specific integration tests
|
||||
- `docker-compose.yml` - Added `mcp-oauth` service
|
||||
- `app-hooks/post-installation/install-oidc-app.sh` - OIDC installation and configuration
|
||||
- `nextcloud_mcp_server/client/__init__.py` - Added `from_token()` classmethod
|
||||
|
||||
## Notes
|
||||
|
||||
- The `from_token()` method was added to NextcloudClient to support OAuth authentication
|
||||
- All OAuth infrastructure is in place and functional
|
||||
- The main limitation is automated token acquisition for testing, not the OAuth implementation itself
|
||||
@@ -6,19 +6,37 @@
|
||||
|
||||
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.
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
|
||||
|
||||
| App | Support | Features |
|
||||
|-----|---------|----------|
|
||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
||||
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
|
||||
| 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) |
|
||||
| **Document Processing** | ✅ OCR with progress (PDF, DOCX, images) | ❌ Not implemented |
|
||||
| **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 |
|
||||
|
||||
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -26,8 +44,17 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments |
|
||||
| **Basic Auth** ⚠️ | Lower | Development, testing |
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
|
||||
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||
|
||||
> [!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.
|
||||
|
||||
@@ -58,29 +85,35 @@ Create a `.env` file:
|
||||
cp env.sample .env
|
||||
```
|
||||
|
||||
**For OAuth (recommended):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**For Basic Auth:**
|
||||
**For Basic Auth (recommended for most users):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
```
|
||||
|
||||
**For OAuth (experimental - requires patches):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for all options.
|
||||
|
||||
### 3. Set Up Authentication
|
||||
|
||||
**OAuth Setup (recommended):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. Enable dynamic client registration
|
||||
3. Configure Bearer token validation
|
||||
4. Start the server
|
||||
**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
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
|
||||
**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
|
||||
|
||||
@@ -88,12 +121,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start the server
|
||||
# Start with Basic Auth (default)
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Or start with OAuth (experimental - requires patches)
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# Or with Docker
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
The server starts on `http://127.0.0.1:8000` by default.
|
||||
@@ -120,12 +156,15 @@ Or connect from:
|
||||
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
||||
- **[Running the Server](docs/running.md)** - Start and manage the server
|
||||
|
||||
### OAuth Documentation
|
||||
### 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)** - Production deployment
|
||||
- **[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
|
||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
|
||||
|
||||
### Reference
|
||||
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
||||
@@ -134,6 +173,7 @@ Or connect from:
|
||||
- [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)
|
||||
@@ -143,16 +183,90 @@ Or connect from:
|
||||
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
||||
|
||||
### Tools
|
||||
Tools enable AI assistants to perform actions:
|
||||
|
||||
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
|
||||
|
||||
For a complete list of all supported OAuth scopes and their descriptions, see [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes).
|
||||
|
||||
#### Available Tool Categories
|
||||
|
||||
| App | Tools | Read Scope | Write Scope | Operations |
|
||||
|-----|-------|-----------|-------------|------------|
|
||||
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes |
|
||||
| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||
| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books |
|
||||
| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** |
|
||||
| **Deck** | 15 | `deck:read` | `deck:write` | Boards, stacks, cards, labels, assignments |
|
||||
| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories |
|
||||
| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares |
|
||||
|
||||
#### Document Processing (Optional)
|
||||
|
||||
The WebDAV file reading tool (`nc_webdav_read_file`) supports **automatic text extraction** from documents and images:
|
||||
|
||||
**Supported Formats:**
|
||||
- **Documents**: PDF, DOCX, PPTX, XLSX, RTF, ODT, EPUB
|
||||
- **Images**: PNG, JPEG, TIFF, BMP (with OCR)
|
||||
- **Email**: EML, MSG files
|
||||
|
||||
**Features:**
|
||||
- **Progress Notifications**: Long-running OCR operations (up to 120s) send progress updates every 10 seconds to prevent client timeouts
|
||||
- **Pluggable Architecture**: Multiple processor backends (Unstructured.io, Tesseract, custom HTTP APIs)
|
||||
- **Automatic Detection**: Files are processed based on MIME type
|
||||
- **Graceful Fallback**: Returns base64-encoded content if processing fails
|
||||
|
||||
**Configuration:**
|
||||
```dotenv
|
||||
# Enable document processing (optional)
|
||||
ENABLE_DOCUMENT_PROCESSING=true
|
||||
|
||||
# Unstructured.io processor (cloud/API-based, supports many formats)
|
||||
ENABLE_UNSTRUCTURED=true
|
||||
UNSTRUCTURED_API_URL=http://localhost:8002
|
||||
UNSTRUCTURED_STRATEGY=auto # auto, fast, or hi_res
|
||||
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||
PROGRESS_INTERVAL=10 # Progress update interval in seconds
|
||||
|
||||
# Tesseract processor (local OCR, images only)
|
||||
ENABLE_TESSERACT=false
|
||||
TESSERACT_LANG=eng
|
||||
|
||||
# Custom HTTP processor
|
||||
ENABLE_CUSTOM_PROCESSOR=false
|
||||
CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||
CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg
|
||||
```
|
||||
|
||||
**Example Usage:**
|
||||
```
|
||||
AI: "Read the contents of Documents/report.pdf"
|
||||
→ Uses nc_webdav_read_file tool with automatic OCR processing
|
||||
→ Returns extracted text with parsing metadata
|
||||
→ Sends progress updates during long operations
|
||||
```
|
||||
|
||||
See [env.sample](env.sample) for complete configuration options.
|
||||
|
||||
**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
|
||||
- And many more...
|
||||
- `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 `notes:read` and `notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes) for the complete scope reference, or [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...
|
||||
@@ -167,6 +281,12 @@ 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"
|
||||
@@ -214,7 +334,8 @@ Contributions are welcome!
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
This project takes security seriously:
|
||||
- OAuth2/OIDC support for secure authentication
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
|
||||
index ee3cd57..6429f94 100644
|
||||
--- a/lib/Util/DiscoveryGenerator.php
|
||||
+++ b/lib/Util/DiscoveryGenerator.php
|
||||
@@ -171,6 +171,11 @@ class DiscoveryGenerator
|
||||
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
|
||||
}
|
||||
|
||||
+ // Add PKCE support if enabled
|
||||
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
|
||||
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
|
||||
+ }
|
||||
+
|
||||
$this->logger->info('Request to Discovery Endpoint.');
|
||||
|
||||
$response = new JSONResponse($discoveryPayload);
|
||||
+5
-1
@@ -6,14 +6,18 @@ 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
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
# 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
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable cookbook
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/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"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/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 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC apps for testing..."
|
||||
|
||||
# Enable the OIDC Identity Provider app
|
||||
php /var/www/html/occ app:enable oidc
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
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
|
||||
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
echo "OIDC apps installed and configured successfully"
|
||||
+30
-5
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:42a36b4711191273a9cf8cebfd35602909eb1bee461b7076d4d5a57f7ec2b81e
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -31,6 +31,9 @@ services:
|
||||
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
|
||||
@@ -39,6 +42,24 @@ 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
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
# Unstructured API runs on port 8000 internally
|
||||
# We expose it on 8002 externally to avoid conflict
|
||||
profiles:
|
||||
- unstructured
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
@@ -55,7 +76,7 @@ services:
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
@@ -63,9 +84,13 @@ services:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||
# No USERNAME/PASSWORD - will use OAuth
|
||||
- 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
|
||||
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,899 @@
|
||||
# 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
|
||||
+548
-123
@@ -8,166 +8,463 @@ The Nextcloud MCP Server acts as an **OAuth 2.0 Resource Server**, protecting ac
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
The complete OAuth flow includes server startup (with DCR), client discovery (with PRM), authorization (with PKCE), and API access phases:
|
||||
|
||||
```
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
┌──────────────────┐ ┌─────────────────┐
|
||||
│ MCP Server │ │ Nextcloud │
|
||||
│ (Resource │ │ (OIDC Provider)│
|
||||
│ Server) │ │ │
|
||||
└────────┬─────────┘ └────────┬────────┘
|
||||
│ │
|
||||
│ 0a. OIDC Discovery │
|
||||
├────────────────────────────────────>│
|
||||
│ GET │
|
||||
| /.well-known/openid-configuration │
|
||||
│ │
|
||||
│ 0b. Discovery response │
|
||||
│<────────────────────────────────────┤
|
||||
│ {issuer, endpoints, PKCE methods} │
|
||||
│ │
|
||||
│ 0c. Register OAuth client (DCR) │
|
||||
├────────────────────────────────────>│
|
||||
│ POST /apps/oidc/register │
|
||||
│ {client_name, redirect_uris, │
|
||||
│ scopes, token_type} │
|
||||
│ │
|
||||
│ 0d. Client credentials │
|
||||
│<────────────────────────────────────┤
|
||||
│ {client_id, client_secret} │
|
||||
│ → Saved to .nextcloud_oauth_*.json │
|
||||
│ │
|
||||
│ ✓ Server ready for connections │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 1: Client Connection & Discovery (PRM - RFC 9728)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ │ │ │ │ │
|
||||
│ MCP Client │ │ MCP Server │ │ Nextcloud │
|
||||
│ (Claude, │ │ (Resource │ │ Instance │
|
||||
│ etc.) │ │ Server) │ │ │
|
||||
│ │ │ MCP Server │ │ Nextcloud │
|
||||
│ MCP Client │ │ (Resource │ │ Instance │
|
||||
│ (Claude) │ │ Server) │ │ │
|
||||
│ │ │ │ │ │
|
||||
└──────┬──────┘ └────────┬─────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ 1. Connect to MCP │ │
|
||||
│ 1a. Connect to MCP │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. Return auth settings │ │
|
||||
│ (issuer_url, scopes) │ │
|
||||
│ 1b. Return auth settings │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {issuer_url, resource_url} │ │
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ 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│ │
|
||||
│ 1c. PRM Discovery (RFC 9728) │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ Authorization: Bearer xxx │ │
|
||||
│ GET /.well-known/oauth- │ │
|
||||
│ protected-resource/mcp │ │
|
||||
│ │ │
|
||||
│ │ 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 │ │
|
||||
│ 1d. PRM response (scopes!) │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {resource, scopes_supported, │ ← Dynamically discovered from │
|
||||
│ authorization_servers} │ @require_scopes decorators │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ │ │
|
||||
│ 2a. Generate PKCE challenge │ │
|
||||
│ code_verifier = random(43-128) │ │
|
||||
│ code_challenge = SHA256(verif.) │ │
|
||||
│ │ │
|
||||
│ 2b. Authorization request │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ /apps/oidc/authorize? │ │
|
||||
│ client_id=xxx │ │
|
||||
│ &code_challenge=abc... │ │
|
||||
│ &code_challenge_method=S256 │ │
|
||||
│ &scope=openid notes:read ... │ │
|
||||
│ │ │
|
||||
│ 2c. User consent page │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ (Browser: Select scopes) │ │
|
||||
│ │ │
|
||||
│ 2d. User grants scopes │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ │ │
|
||||
│ 2e. Authorization code redirect │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ callback?code=xyz123 │ │
|
||||
│ │ │
|
||||
│ 2f. Exchange code for token │ │
|
||||
├──────────────────────────────────┼────────────────────────────────────>│
|
||||
│ POST /apps/oidc/token │ │
|
||||
│ {code, code_verifier, │ ← Validates PKCE challenge │
|
||||
│ client_id, client_secret} │ │
|
||||
│ │ │
|
||||
│ 2g. Access token (JWT/opaque) │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ {access_token, token_type, │ │
|
||||
│ scope: "openid notes:read...") │ ← User's granted scopes │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Phase 3: MCP Tool Access (Scope-based Authorization)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ │ │
|
||||
│ 3a. list_tools request │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 3b. Validate token │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ GET /apps/oidc/userinfo │
|
||||
│ │ Authorization: Bearer <token> │
|
||||
│ │ │
|
||||
│ │ 3c. Token valid + scopes │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ {sub, scopes, ...} │
|
||||
│ │ ← Cached for 1 hour │
|
||||
│ │ │
|
||||
│ 3d. Filtered tool list │ │
|
||||
│<─────────────────────────────────┤ ← Only tools matching user's │
|
||||
│ [tools matching token scopes] │ token scopes (via @require_scopes)
|
||||
│ │ │
|
||||
│ 3e. Call tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 3f. Scope check PASSED │
|
||||
│ │ ✓ Token has notes:read │
|
||||
│ │ │
|
||||
│ │ 3g. Nextcloud API call │
|
||||
│ ├────────────────────────────────────>│
|
||||
│ │ GET /apps/notes/api/v1/notes/1 │
|
||||
│ │ Authorization: Bearer <token> │
|
||||
│ │ ← user_oidc validates Bearer token │
|
||||
│ │ │
|
||||
│ │ 3h. API response │
|
||||
│ │<────────────────────────────────────┤
|
||||
│ │ {id: 1, title: "Note", ...} │
|
||||
│ │ │
|
||||
│ 3i. MCP tool response │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ {note data} │ │
|
||||
│ │ │
|
||||
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
Insufficient Scope Example (Step-Up Authorization)
|
||||
═══════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
│ 4a. Call write tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 4b. Scope check FAILED │
|
||||
│ │ ✗ Token only has notes:read │
|
||||
│ │ │
|
||||
│ 4c. 403 Insufficient Scope │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ WWW-Authenticate: Bearer │ │
|
||||
│ error="insufficient_scope", │ │
|
||||
│ scope="notes:write", │ │
|
||||
│ resource_metadata="..." │ │
|
||||
│ │ │
|
||||
│ → Client can re-authorize with │ │
|
||||
│ additional scopes (Step-Up) │ │
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. MCP Client
|
||||
- Any MCP-compatible client (Claude Desktop, Claude Code, custom clients)
|
||||
### 1. MCP Client (e.g., Claude Desktop, Claude Code)
|
||||
|
||||
**Capabilities**:
|
||||
- Discovers OAuth configuration via MCP server
|
||||
- Queries PRM endpoint for supported scopes
|
||||
- Initiates OAuth flow with PKCE (Proof Key for Code Exchange)
|
||||
- Stores and sends access token with each request
|
||||
- **Example**: Claude Desktop, Claude Code
|
||||
- Handles scope-based tool filtering
|
||||
- Supports step-up authorization (re-auth for additional scopes)
|
||||
|
||||
### 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
|
||||
**Examples**: Claude Desktop, Claude Code, MCP Inspector, custom MCP clients
|
||||
|
||||
### 2. MCP Server (Resource Server - This Implementation)
|
||||
|
||||
**Role**: OAuth 2.0 Resource Server (RFC 6749)
|
||||
|
||||
**Responsibilities**:
|
||||
|
||||
#### Startup Phase
|
||||
- **OIDC Discovery**: Queries `/.well-known/openid-configuration` for OAuth endpoints
|
||||
- **PKCE Validation**: Verifies server advertises S256 code challenge method
|
||||
- **Dynamic Client Registration (DCR)**: Automatically registers OAuth client via `/apps/oidc/register` (RFC 7591)
|
||||
- Or loads pre-configured client credentials
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
|
||||
|
||||
#### Client Connection Phase
|
||||
- **Auth Settings**: Returns OAuth issuer URL and resource URL
|
||||
- **PRM Endpoint**: Exposes `/.well-known/oauth-protected-resource/mcp` (RFC 9728)
|
||||
- Dynamically discovers scopes from all registered tools
|
||||
- Returns `scopes_supported` list based on `@require_scopes` decorators
|
||||
|
||||
#### Request Processing Phase
|
||||
- **Token Validation**: Validates Bearer tokens via Nextcloud userinfo endpoint
|
||||
- Supports both JWT and opaque tokens
|
||||
- Caches validation results (1-hour TTL)
|
||||
- Extracts user identity and granted scopes
|
||||
- **Scope Enforcement**:
|
||||
- Filters `list_tools` based on user's token scopes
|
||||
- Validates scopes before executing each tool
|
||||
- Returns 403 with `WWW-Authenticate` header for insufficient scopes
|
||||
- **Per-User Clients**: Creates authenticated `NextcloudClient` instance per user
|
||||
- Uses Bearer token for all Nextcloud API requests
|
||||
- User-specific permissions and audit trails
|
||||
|
||||
**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
|
||||
- [`app.py`](../nextcloud_mcp_server/app.py) - OAuth mode, DCR, PRM endpoint
|
||||
- [`auth/token_verifier.py`](../nextcloud_mcp_server/auth/token_verifier.py) - Token validation (userinfo + introspection + JWT)
|
||||
- [`auth/context_helper.py`](../nextcloud_mcp_server/auth/context_helper.py) - Per-user client creation
|
||||
- [`auth/scope_authorization.py`](../nextcloud_mcp_server/auth/scope_authorization.py) - `@require_scopes` decorator, scope discovery
|
||||
- [`auth/client_registration.py`](../nextcloud_mcp_server/auth/client_registration.py) - DCR implementation (RFC 7591)
|
||||
|
||||
### 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
|
||||
|
||||
**Role**: OAuth 2.0 Authorization Server + OIDC Provider
|
||||
|
||||
**Location**: Nextcloud app (`apps/oidc`)
|
||||
|
||||
**Endpoints**:
|
||||
- `/.well-known/openid-configuration` - OIDC Discovery (RFC 8414)
|
||||
- `/apps/oidc/authorize` - Authorization endpoint (OAuth 2.0 + PKCE)
|
||||
- `/apps/oidc/token` - Token endpoint (issues JWT or opaque tokens)
|
||||
- `/apps/oidc/userinfo` - UserInfo endpoint (OIDC Core, used for token validation)
|
||||
- `/apps/oidc/jwks` - JSON Web Key Set (for JWT signature verification)
|
||||
- `/apps/oidc/register` - Dynamic Client Registration endpoint (RFC 7591)
|
||||
- `/apps/oidc/introspect` - Token Introspection endpoint (RFC 7662, optional)
|
||||
|
||||
**Token Types**:
|
||||
- **JWT tokens**: Self-contained tokens with embedded scopes, validated via JWKS or userinfo
|
||||
- **Opaque tokens**: Random strings, validated via userinfo or introspection endpoint
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
# Enable dynamic client registration (optional)
|
||||
# Settings → OIDC → "Allow dynamic client registration"
|
||||
# Enable dynamic client registration (recommended for development)
|
||||
# Nextcloud Admin → Settings → OIDC → "Allow dynamic client registration"
|
||||
|
||||
# Enable token introspection (optional, for opaque token validation)
|
||||
# Nextcloud Admin → Settings → OIDC → "Enable token introspection"
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
**Role**: Bearer token validation middleware for Nextcloud APIs
|
||||
|
||||
**Location**: Nextcloud app (`apps/user_oidc`)
|
||||
|
||||
**Responsibilities**:
|
||||
- Intercepts Nextcloud API requests with `Authorization: Bearer` header
|
||||
- Validates tokens against OIDC provider (`oidc` app)
|
||||
- Creates authenticated user sessions
|
||||
- Enforces user-specific permissions on API requests
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
# Enable Bearer token validation (required)
|
||||
# Enable Bearer token validation (required for OAuth mode)
|
||||
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.
|
||||
> The `user_oidc` app requires a patch to properly support Bearer token authentication for non-OCS endpoints (like Notes API, Calendar API). See [Upstream Status](oauth-upstream-status.md) for patch details and PR status.
|
||||
|
||||
### 4. Nextcloud Instance
|
||||
- **Role**: Resource Owner / API Provider
|
||||
- **Provides**: Notes, Calendar, Contacts, Deck, Files, etc.
|
||||
|
||||
**Role**: Resource Owner + API Provider
|
||||
|
||||
**APIs Exposed**:
|
||||
- **Notes API**: `/apps/notes/api/v1/` - Note CRUD operations
|
||||
- **Calendar (CalDAV)**: `/remote.php/dav/calendars/` - Events and todos
|
||||
- **Contacts (CardDAV)**: `/remote.php/dav/addressbooks/` - Contact management
|
||||
- **Cookbook API**: `/apps/cookbook/api/v1/` - Recipe management
|
||||
- **Deck API**: `/apps/deck/api/v1.0/` - Kanban boards
|
||||
- **Tables API**: `/apps/tables/api/2/` - Table row operations
|
||||
- **WebDAV (Files)**: `/remote.php/dav/files/` - File operations
|
||||
- **Sharing API**: `/ocs/v2.php/apps/files_sharing/api/v1/` - Share management
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### Phase 1: OAuth Authorization (Steps 1-7)
|
||||
The OAuth flow consists of four distinct phases (see diagram above for visual representation):
|
||||
|
||||
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 0: MCP Server Startup (One-time Setup)
|
||||
|
||||
### Phase 2: API Access (Steps 8-13)
|
||||
**Happens**: On MCP server first startup
|
||||
|
||||
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
|
||||
**Steps**:
|
||||
1. **OIDC Discovery** (`GET /.well-known/openid-configuration`)
|
||||
- MCP server queries Nextcloud for OAuth endpoints
|
||||
- Validates PKCE support (requires `S256` code challenge method)
|
||||
- Extracts endpoints: authorize, token, userinfo, jwks, register
|
||||
|
||||
2. **Dynamic Client Registration** (`POST /apps/oidc/register`)
|
||||
- If no pre-configured client credentials exist
|
||||
- MCP server registers itself as OAuth client (RFC 7591)
|
||||
- Provides: client name, redirect URIs, requested scopes, token type
|
||||
- Receives: `client_id`, `client_secret`
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
|
||||
3. **Tool Registration**
|
||||
- All MCP tools loaded with their `@require_scopes` decorators
|
||||
- Scope metadata stored for later discovery
|
||||
|
||||
**Result**: MCP server ready to accept client connections
|
||||
|
||||
### Phase 1: Client Discovery (Per MCP Client Connection)
|
||||
|
||||
**Happens**: When MCP client first connects
|
||||
|
||||
**Steps**:
|
||||
1. **MCP Connection**
|
||||
- Client connects to MCP server
|
||||
- Server returns OAuth auth settings (issuer URL, resource URL)
|
||||
|
||||
2. **PRM Discovery** (`GET /.well-known/oauth-protected-resource/mcp`)
|
||||
- Client queries Protected Resource Metadata endpoint (RFC 9728)
|
||||
- Server **dynamically discovers** scopes from all registered tools
|
||||
- Returns: resource URL, `scopes_supported` list, authorization servers
|
||||
- Client now knows which scopes are available
|
||||
|
||||
**Result**: Client knows OAuth configuration and available scopes
|
||||
|
||||
### Phase 2: OAuth Authorization (PKCE Flow - RFC 7636)
|
||||
|
||||
**Happens**: User authorizes access
|
||||
|
||||
**Steps**:
|
||||
1. **PKCE Challenge Generation** (Client-side)
|
||||
- Generate `code_verifier`: random 43-128 character string
|
||||
- Calculate `code_challenge`: `BASE64URL(SHA256(code_verifier))`
|
||||
|
||||
2. **Authorization Request** (`GET /apps/oidc/authorize`)
|
||||
- Client redirects user to Nextcloud consent page
|
||||
- Parameters:
|
||||
- `client_id`: OAuth client ID
|
||||
- `code_challenge`: SHA256 hash of verifier
|
||||
- `code_challenge_method`: `S256`
|
||||
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
|
||||
- `redirect_uri`: MCP server callback URL
|
||||
|
||||
3. **User Consent**
|
||||
- User authenticates to Nextcloud (if not already logged in)
|
||||
- User reviews and approves/denies requested scopes
|
||||
- Can select subset of requested scopes
|
||||
|
||||
4. **Authorization Code**
|
||||
- Nextcloud redirects to `callback?code=xyz123`
|
||||
- Code is bound to PKCE challenge
|
||||
|
||||
5. **Token Exchange** (`POST /apps/oidc/token`)
|
||||
- Client sends:
|
||||
- Authorization `code`
|
||||
- `code_verifier` (proves possession of original challenge)
|
||||
- `client_id` and `client_secret`
|
||||
- Nextcloud validates PKCE challenge: `SHA256(code_verifier) == code_challenge`
|
||||
- Nextcloud issues access token
|
||||
|
||||
6. **Access Token Response**
|
||||
- Token type: JWT or opaque (configurable)
|
||||
- Contains user's **granted scopes** (may be subset of requested)
|
||||
- Client stores token for subsequent requests
|
||||
|
||||
**Result**: Client has valid access token with granted scopes
|
||||
|
||||
### Phase 3: MCP Tool Access (Scope-Based Authorization)
|
||||
|
||||
**Happens**: Every MCP tool invocation
|
||||
|
||||
**Steps**:
|
||||
|
||||
#### Tool Listing (`list_tools`)
|
||||
1. **List Tools Request**
|
||||
- Client sends `list_tools` with `Authorization: Bearer <token>`
|
||||
|
||||
2. **Token Validation**
|
||||
- MCP server calls `/apps/oidc/userinfo` with Bearer token
|
||||
- Nextcloud returns user info including **granted scopes**
|
||||
- Result cached for 1 hour
|
||||
|
||||
3. **Dynamic Tool Filtering**
|
||||
- Server compares token scopes with each tool's `@require_scopes`
|
||||
- Only returns tools where user has all required scopes
|
||||
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
|
||||
|
||||
4. **Filtered Tool List**
|
||||
- Client receives only tools they can use
|
||||
|
||||
#### Tool Execution (e.g., `nc_notes_get_note`)
|
||||
1. **Tool Call**
|
||||
- Client invokes tool with `Authorization: Bearer <token>`
|
||||
|
||||
2. **Scope Validation**
|
||||
- `@require_scopes` decorator extracts token scopes
|
||||
- Verifies token contains required scope (e.g., `notes:read`)
|
||||
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
|
||||
- If present → continues execution
|
||||
|
||||
3. **Nextcloud API Call**
|
||||
- MCP server creates `NextcloudClient` with Bearer token
|
||||
- Calls Nextcloud API (e.g., `GET /apps/notes/api/v1/notes/1`)
|
||||
- `user_oidc` app validates Bearer token again
|
||||
- Request executes as authenticated user
|
||||
|
||||
4. **Response**
|
||||
- Nextcloud returns data
|
||||
- MCP server formats response
|
||||
- Returns to client
|
||||
|
||||
**Result**: User can only access tools and data they have permissions for
|
||||
|
||||
### Phase 4: Insufficient Scope Handling (Step-Up Authorization)
|
||||
|
||||
**Happens**: When user lacks required scopes
|
||||
|
||||
**Steps**:
|
||||
1. **Tool Call with Insufficient Scopes**
|
||||
- User calls `nc_notes_create_note` (requires `notes:write`)
|
||||
- But token only has `notes:read`
|
||||
|
||||
2. **Scope Validation Fails**
|
||||
- `@require_scopes("notes:write")` decorator checks token
|
||||
- Finds `notes:write` missing
|
||||
|
||||
3. **403 Response with Challenge**
|
||||
- Returns `403 Forbidden`
|
||||
- Includes `WWW-Authenticate` header:
|
||||
```
|
||||
Bearer error="insufficient_scope",
|
||||
scope="notes:write",
|
||||
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
4. **Client Re-Authorization** (Optional)
|
||||
- Client can initiate new OAuth flow requesting additional scopes
|
||||
- User re-consents with expanded permissions
|
||||
- New token includes both `notes:read` and `notes:write`
|
||||
|
||||
**Result**: User can dynamically upgrade permissions without full re-authentication
|
||||
|
||||
## Token Validation
|
||||
|
||||
@@ -217,11 +514,12 @@ 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 new client
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
||||
4. Re-registers if credentials expire
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
### Pre-configured Client
|
||||
|
||||
@@ -271,14 +569,145 @@ client = get_client_from_context(ctx)
|
||||
- Protects against authorization code interception
|
||||
|
||||
### Scopes
|
||||
- Required scopes: `openid`, `profile`
|
||||
- Additional scopes inferred from userinfo response
|
||||
- Base required scopes: `openid`, `profile`, `email`
|
||||
- App-specific scopes control access to individual Nextcloud apps
|
||||
- See [OAuth Scopes](#oauth-scopes) section for complete scope reference
|
||||
|
||||
### Token Validation
|
||||
- Every MCP request validates Bearer token
|
||||
- Cached for performance (1-hour default)
|
||||
- Calls userinfo endpoint for validation
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
The Nextcloud MCP Server implements fine-grained OAuth scopes for each Nextcloud app integration. Scopes control which tools are visible and accessible to users based on their granted permissions.
|
||||
|
||||
### Scope-Based Access Control
|
||||
|
||||
When using OAuth authentication:
|
||||
1. **Dynamic Discovery**: The server automatically discovers all required scopes from `@require_scopes` decorators on MCP tools
|
||||
2. **Tool Filtering**: Tools are dynamically filtered based on the user's token scopes - users only see tools they have permission to use
|
||||
3. **Per-Tool Enforcement**: Each tool validates required scopes before execution, returning a 403 error if insufficient scopes are present
|
||||
|
||||
### Supported Scopes
|
||||
|
||||
The server supports the following OAuth scopes, organized by Nextcloud app:
|
||||
|
||||
#### Base OIDC Scopes
|
||||
- `openid` - OpenID Connect authentication (required)
|
||||
- `profile` - Access to user profile information (required)
|
||||
- `email` - Access to user email address (required)
|
||||
|
||||
#### Notes App
|
||||
- `notes:read` - Read notes, search notes, get note attachments
|
||||
- `notes:write` - Create, update, append to, and delete notes
|
||||
|
||||
#### Calendar App
|
||||
- `calendar:read` - List calendars, read events, search events
|
||||
- `calendar:write` - Create, update, and delete calendars and events
|
||||
|
||||
#### Calendar Tasks (VTODO)
|
||||
- `todo:read` - List and read CalDAV tasks
|
||||
- `todo:write` - Create, update, and delete CalDAV tasks
|
||||
|
||||
#### Contacts App
|
||||
- `contacts:read` - List address books and read contacts (CardDAV)
|
||||
- `contacts:write` - Create, update, and delete address books and contacts
|
||||
|
||||
#### Cookbook App
|
||||
- `cookbook:read` - Read recipes, search recipes
|
||||
- `cookbook:write` - Create, update, and delete recipes
|
||||
|
||||
#### Deck App
|
||||
- `deck:read` - List boards, stacks, cards, and labels
|
||||
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
|
||||
|
||||
#### Tables App
|
||||
- `tables:read` - List tables and read rows
|
||||
- `tables:write` - Create, update, and delete rows in tables
|
||||
|
||||
#### Files (WebDAV)
|
||||
- `files:read` - List files, read file contents, search files
|
||||
- `files:write` - Upload, update, move, copy, and delete files
|
||||
|
||||
#### Sharing
|
||||
- `sharing:read` - List shares and read share information
|
||||
- `sharing:write` - Create, update, and delete shares
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
The MCP server provides scope discovery through two mechanisms:
|
||||
|
||||
#### 1. Protected Resource Metadata (PRM) Endpoint
|
||||
```bash
|
||||
# Query the PRM endpoint
|
||||
curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
|
||||
|
||||
# Response includes dynamically discovered scopes
|
||||
{
|
||||
"resource": "http://localhost:8000/mcp",
|
||||
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
|
||||
"authorization_servers": ["https://nextcloud.example.com"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
}
|
||||
```
|
||||
|
||||
The `scopes_supported` field is **dynamically generated** from all registered MCP tools, ensuring it always reflects the actual available scopes.
|
||||
|
||||
#### 2. Scope Enforcement via Decorators
|
||||
|
||||
Tools are decorated with `@require_scopes()` to declare their required permissions:
|
||||
|
||||
```python
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
"""Get a specific note by ID"""
|
||||
# Implementation
|
||||
```
|
||||
|
||||
### Client Registration Scopes
|
||||
|
||||
During OAuth client registration (dynamic or manual), clients request a set of scopes that define the **maximum allowed** scopes for that client. The actual per-tool enforcement is handled separately via decorators.
|
||||
|
||||
**Environment Variable**:
|
||||
```bash
|
||||
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
|
||||
```
|
||||
|
||||
**Default**: All supported scopes (recommended for development)
|
||||
|
||||
> **Note**: Client registration scopes define the maximum permissions. The MCP server's PRM endpoint dynamically advertises the actual supported scopes based on registered tools.
|
||||
|
||||
### Step-Up Authorization
|
||||
|
||||
The server supports OAuth step-up authorization (RFC 8693). If a user attempts to use a tool requiring scopes they don't have:
|
||||
|
||||
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
|
||||
2. Response includes `WWW-Authenticate` header listing missing scopes:
|
||||
```
|
||||
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
|
||||
```
|
||||
3. Client can re-authorize with additional scopes
|
||||
|
||||
### Scope Validation
|
||||
|
||||
All scope enforcement happens at two levels:
|
||||
|
||||
1. **Tool Visibility**: During `list_tools` requests, only tools matching the user's token scopes are returned
|
||||
2. **Execution Time**: When calling a tool, the `@require_scopes` decorator validates the token has necessary scopes
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# User token has: ["openid", "profile", "email", "notes:read"]
|
||||
# They will see: 4 read-only notes tools
|
||||
# They will NOT see: 3 write notes tools (notes:write required)
|
||||
# Attempting to call a write tool returns 403 Forbidden
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
@@ -295,8 +724,7 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
|
||||
|
||||
The integration test suite includes comprehensive OAuth testing:
|
||||
|
||||
- **Automated tests** (Playwright): [`tests/integration/test_oauth_playwright.py`](../tests/integration/test_oauth_playwright.py)
|
||||
- **Interactive tests**: [`tests/integration/test_oauth_interactive.py`](../tests/integration/test_oauth_interactive.py)
|
||||
- **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:
|
||||
@@ -305,10 +733,7 @@ Run OAuth tests:
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run automated tests
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Run interactive tests (manual login)
|
||||
uv run pytest tests/integration/test_oauth_interactive.py -v
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
+7
-7
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
|
||||
|
||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
**How it works**:
|
||||
- MCP server automatically registers OAuth client at startup
|
||||
- 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
|
||||
- No manual client management
|
||||
- Automatic credential management
|
||||
|
||||
**Cons**:
|
||||
- Clients expire (default: 1 hour, configurable)
|
||||
- Must re-register on restart if expired
|
||||
- Not ideal for long-running production
|
||||
- Must have dynamic client registration enabled on Nextcloud
|
||||
|
||||
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
||||
|
||||
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
|
||||
**Best for**: Production, long-running deployments, stable environments
|
||||
|
||||
**How it works**:
|
||||
- You manually register OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server
|
||||
- You manually register an OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server via environment variables
|
||||
- Credentials don't expire
|
||||
|
||||
**Pros**:
|
||||
|
||||
@@ -14,6 +14,7 @@ Start here to identify your issue:
|
||||
| "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) |
|
||||
@@ -407,6 +408,94 @@ http://localhost:8000/oauth/callback
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. PKCE Support Advertisement in Discovery
|
||||
### 2. PKCE Support (RFC 7636)
|
||||
|
||||
**Status**: 🟢 **PR Submitted** (Pending Review)
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
|
||||
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
|
||||
|
||||
**Why It Matters**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Some MCP clients may reject providers without proper PKCE advertisement
|
||||
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
|
||||
|
||||
**Current Behavior**:
|
||||
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
|
||||
- PKCE just isn't **advertised** in discovery metadata
|
||||
**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
|
||||
|
||||
**Recommended Fix**: Update `oidc` app to include:
|
||||
**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"]
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
|
||||
**Database**:
|
||||
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
|
||||
- Migration included for existing installations
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
|
||||
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
|
||||
- **Size**: +5 lines added, 0 deleted
|
||||
- **Status**: Open, awaiting review
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
| 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` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
|
||||
| [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
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE
|
||||
- Token exchange
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
|
||||
- Tables API
|
||||
- Custom app APIs
|
||||
|
||||
🟡 **Standards Compliance** (PKCE advertisement):
|
||||
- Full RFC 8414 compliance
|
||||
- MCP client compatibility guarantee
|
||||
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||
- ✅ MCP client compatibility guarantee
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
@@ -171,7 +187,7 @@ The integration test suite validates OAuth functionality:
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run comprehensive OAuth tests
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Tests verify:
|
||||
# - OAuth flow completion
|
||||
@@ -221,6 +237,6 @@ Want to help get these patches merged?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-14
|
||||
**Last Updated**: 2025-10-20
|
||||
|
||||
**Next Review**: When PR #584 or issue #1221 has activity
|
||||
**Next Review**: When issue #1221 (Bearer token support) has activity
|
||||
|
||||
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
This quick start uses **automatic client registration** which is perfect for:
|
||||
- Development
|
||||
- Testing
|
||||
- Short-lived deployments
|
||||
- Quick deployments
|
||||
|
||||
For **production deployments**, you should:
|
||||
1. Pre-register OAuth clients manually
|
||||
2. Use dedicated client credentials
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,412 @@
|
||||
# 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
|
||||
+74
@@ -21,3 +21,77 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
# - If these are set, OAuth mode is disabled
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
|
||||
# ============================================
|
||||
# Document Processing Configuration
|
||||
# ============================================
|
||||
# Enable document processing (PDF, DOCX, images, etc.)
|
||||
# Set to false to disable all document processing
|
||||
ENABLE_DOCUMENT_PROCESSING=false
|
||||
|
||||
# Default processor to use when multiple are available
|
||||
# Options: unstructured, tesseract, custom
|
||||
DOCUMENT_PROCESSOR=unstructured
|
||||
|
||||
# ============================================
|
||||
# Unstructured.io Processor
|
||||
# ============================================
|
||||
# Enable Unstructured processor (requires unstructured service in docker-compose)
|
||||
# This is a cloud-based/API processor supporting many document types
|
||||
ENABLE_UNSTRUCTURED=false
|
||||
|
||||
# Unstructured API endpoint
|
||||
UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||
|
||||
# Request timeout in seconds (default: 120)
|
||||
# OCR operations can take 30-120 seconds for large documents
|
||||
UNSTRUCTURED_TIMEOUT=120
|
||||
|
||||
# Parsing strategy: auto, fast, hi_res
|
||||
# - auto: Automatically choose based on document type
|
||||
# - fast: Fast parsing without OCR
|
||||
# - hi_res: High-resolution with OCR (slowest, most accurate)
|
||||
UNSTRUCTURED_STRATEGY=auto
|
||||
|
||||
# OCR languages (comma-separated ISO 639-3 codes)
|
||||
# Common: eng=English, deu=German, fra=French, spa=Spanish
|
||||
UNSTRUCTURED_LANGUAGES=eng,deu
|
||||
|
||||
# Progress reporting interval in seconds (default: 10)
|
||||
# During long-running OCR operations, progress notifications are sent to the MCP client
|
||||
# at this interval to prevent timeouts and provide status updates
|
||||
PROGRESS_INTERVAL=10
|
||||
|
||||
# ============================================
|
||||
# Tesseract Processor (Local OCR)
|
||||
# ============================================
|
||||
# Enable Tesseract processor (requires tesseract binary installed)
|
||||
# This is a local, lightweight OCR solution for images only
|
||||
ENABLE_TESSERACT=false
|
||||
|
||||
# Path to tesseract executable (optional, auto-detected if in PATH)
|
||||
#TESSERACT_CMD=/usr/bin/tesseract
|
||||
|
||||
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
||||
TESSERACT_LANG=eng
|
||||
|
||||
# ============================================
|
||||
# Custom Processor (Your own API)
|
||||
# ============================================
|
||||
# Enable custom document processor via HTTP API
|
||||
ENABLE_CUSTOM_PROCESSOR=false
|
||||
|
||||
# Unique name for your processor
|
||||
#CUSTOM_PROCESSOR_NAME=my_ocr
|
||||
|
||||
# Your custom processor API endpoint
|
||||
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||
|
||||
# Optional API key for authentication
|
||||
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
|
||||
|
||||
# Request timeout in seconds
|
||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||
|
||||
# Comma-separated MIME types your processor supports
|
||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||
|
||||
+499
-106
@@ -5,22 +5,39 @@ from contextlib import AsyncExitStack, asynccontextmanager
|
||||
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.routing import Mount
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
NextcloudTokenVerifier,
|
||||
discover_all_scopes,
|
||||
get_access_token_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
from nextcloud_mcp_server.config import (
|
||||
LOGGING_CONFIG,
|
||||
get_document_processor_config,
|
||||
setup_logging,
|
||||
)
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
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,
|
||||
)
|
||||
@@ -28,6 +45,92 @@ from nextcloud_mcp_server.server import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def initialize_document_processors():
|
||||
"""Initialize and register document processors based on configuration.
|
||||
|
||||
This function reads the environment configuration and registers available
|
||||
processors (Unstructured, Tesseract, Custom HTTP) with the global registry.
|
||||
"""
|
||||
config = get_document_processor_config()
|
||||
|
||||
if not config["enabled"]:
|
||||
logger.info("Document processing disabled")
|
||||
return
|
||||
|
||||
registry = get_registry()
|
||||
registered_count = 0
|
||||
|
||||
# Register Unstructured processor
|
||||
if "unstructured" in config["processors"]:
|
||||
unst_config = config["processors"]["unstructured"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=unst_config["api_url"],
|
||||
timeout=unst_config["timeout"],
|
||||
default_strategy=unst_config["strategy"],
|
||||
default_languages=unst_config["languages"],
|
||||
progress_interval=unst_config.get("progress_interval", 10),
|
||||
)
|
||||
registry.register(processor, priority=10)
|
||||
logger.info(f"Registered Unstructured processor: {unst_config['api_url']}")
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Unstructured processor: {e}")
|
||||
|
||||
# Register Tesseract processor
|
||||
if "tesseract" in config["processors"]:
|
||||
tess_config = config["processors"]["tesseract"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.tesseract import (
|
||||
TesseractProcessor,
|
||||
)
|
||||
|
||||
processor = TesseractProcessor(
|
||||
tesseract_cmd=tess_config.get("tesseract_cmd"),
|
||||
default_lang=tess_config["lang"],
|
||||
)
|
||||
registry.register(processor, priority=5)
|
||||
logger.info(f"Registered Tesseract processor: lang={tess_config['lang']}")
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Tesseract processor: {e}")
|
||||
|
||||
# Register custom processor
|
||||
if "custom" in config["processors"]:
|
||||
custom_config = config["processors"]["custom"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.custom_http import (
|
||||
CustomHTTPProcessor,
|
||||
)
|
||||
|
||||
processor = CustomHTTPProcessor(
|
||||
name=custom_config["name"],
|
||||
api_url=custom_config["api_url"],
|
||||
api_key=custom_config.get("api_key"),
|
||||
timeout=custom_config["timeout"],
|
||||
supported_types=custom_config["supported_types"],
|
||||
)
|
||||
registry.register(processor, priority=1)
|
||||
logger.info(
|
||||
f"Registered Custom processor '{custom_config['name']}': {custom_config['api_url']}"
|
||||
)
|
||||
registered_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register Custom processor: {e}")
|
||||
|
||||
if registered_count > 0:
|
||||
logger.info(
|
||||
f"Document processing initialized with {registered_count} processor(s): "
|
||||
f"{', '.join(registry.list_processors())}"
|
||||
)
|
||||
else:
|
||||
logger.warning("Document processing enabled but no processors registered")
|
||||
|
||||
|
||||
def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
|
||||
"""
|
||||
Validate that the OIDC provider properly advertises PKCE support.
|
||||
@@ -132,6 +235,114 @@ def is_oauth_mode() -> bool:
|
||||
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
|
||||
# Note: Client registration happens BEFORE tools are registered, so we can't
|
||||
# dynamically discover scopes here. These scopes define the "maximum allowed"
|
||||
# scopes for this OAuth client. The actual per-tool scope enforcement happens
|
||||
# via @require_scopes decorators, and the PRM endpoint advertises the actual
|
||||
# supported scopes dynamically.
|
||||
#
|
||||
# IMPORTANT: Keep this list in sync with all @require_scopes decorators
|
||||
# when adding new apps, or set NEXTCLOUD_OIDC_SCOPES environment variable
|
||||
# to override.
|
||||
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]:
|
||||
"""
|
||||
@@ -146,6 +357,9 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
client = NextcloudClient.from_env()
|
||||
logger.info("Client initialization complete")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
@@ -174,8 +388,6 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
|
||||
try:
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -186,49 +398,31 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
# 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}")
|
||||
|
||||
# Handle client registration
|
||||
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
if client_id and client_secret:
|
||||
logger.info("Using pre-configured OAuth client credentials")
|
||||
elif 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"]
|
||||
|
||||
# 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="Nextcloud MCP Server",
|
||||
redirect_uris=redirect_uris,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
else:
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
|
||||
# Create token verifier
|
||||
# Create token verifier with introspection support
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||
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")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield OAuthAppContext(
|
||||
nextcloud_host=nextcloud_host, token_verifier=token_verifier
|
||||
@@ -264,8 +458,6 @@ async def setup_oauth_config():
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -279,59 +471,60 @@ async def setup_oauth_config():
|
||||
# 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")
|
||||
|
||||
# Allow override of public issuer URL for clients
|
||||
# (useful when MCP server accesses Nextcloud via internal URL
|
||||
# but needs to advertise a different URL to clients)
|
||||
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: {public_issuer}")
|
||||
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
|
||||
|
||||
# Handle client registration
|
||||
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")
|
||||
elif registration_endpoint:
|
||||
logger.info("Dynamic client registration available")
|
||||
storage_path = os.getenv(
|
||||
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||
)
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||
|
||||
# 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="Nextcloud MCP Server",
|
||||
redirect_uris=redirect_uris,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
jwt_validation_issuer = public_issuer
|
||||
else:
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
# Use discovered issuer for both
|
||||
jwt_validation_issuer = issuer
|
||||
|
||||
# Create token verifier
|
||||
# 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
|
||||
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),
|
||||
required_scopes=["openid", "profile"],
|
||||
)
|
||||
|
||||
logger.info("OAuth configuration complete")
|
||||
@@ -348,11 +541,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
# Asynchronously get the OAuth configuration
|
||||
import asyncio
|
||||
import anyio
|
||||
|
||||
nextcloud_host, token_verifier, auth_settings = asyncio.run(
|
||||
setup_oauth_config()
|
||||
)
|
||||
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
@@ -375,8 +566,10 @@ 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,
|
||||
}
|
||||
|
||||
@@ -394,6 +587,55 @@ 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_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
@@ -406,7 +648,104 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
|
||||
# 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.
|
||||
|
||||
Dynamically discovers supported scopes from registered MCP tools.
|
||||
This ensures the advertised scopes always match the actual tool requirements.
|
||||
"""
|
||||
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", "")
|
||||
|
||||
# Dynamically discover all scopes from registered tools
|
||||
# This provides a single source of truth based on @require_scopes decorators
|
||||
supported_scopes = discover_all_scopes(mcp)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": resource_url,
|
||||
"scopes_supported": supported_scopes,
|
||||
"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=["*"],
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -418,10 +757,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@click.option(
|
||||
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||
)
|
||||
@click.option(
|
||||
"--workers", "-w", type=int, default=None, help="Number of worker processes"
|
||||
)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
@@ -442,7 +777,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"--enable-app",
|
||||
"-e",
|
||||
multiple=True,
|
||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||
type=click.Choice(
|
||||
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
||||
),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
@click.option(
|
||||
@@ -474,11 +811,44 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
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 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",
|
||||
show_default=True,
|
||||
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (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, ...],
|
||||
@@ -487,6 +857,12 @@ def run(
|
||||
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.
|
||||
@@ -498,24 +874,52 @@ def run(
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# BasicAuth mode (legacy)
|
||||
# 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 --oauth
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
||||
|
||||
# OAuth mode with pre-configured client
|
||||
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
$ 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 OAuth env vars from CLI options if provided
|
||||
# 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:
|
||||
@@ -585,21 +989,10 @@ def run(
|
||||
|
||||
enabled_apps = list(enable_app) if enable_app else None
|
||||
|
||||
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
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
|
||||
uvicorn.run(
|
||||
app=app,
|
||||
factory=factory,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
workers=workers,
|
||||
log_level=log_level,
|
||||
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
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,
|
||||
discover_all_scopes,
|
||||
get_access_token_scopes,
|
||||
get_required_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
require_scopes,
|
||||
)
|
||||
from .token_verifier import NextcloudTokenVerifier
|
||||
|
||||
__all__ = [
|
||||
@@ -11,4 +22,13 @@ __all__ = [
|
||||
"register_client",
|
||||
"load_or_register_client",
|
||||
"get_client_from_context",
|
||||
"require_scopes",
|
||||
"ScopeAuthorizationError",
|
||||
"InsufficientScopeError",
|
||||
"check_scopes",
|
||||
"discover_all_scopes",
|
||||
"get_access_token_scopes",
|
||||
"get_required_scopes",
|
||||
"has_required_scopes",
|
||||
"is_jwt_token",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Dynamic client registration for Nextcloud OIDC."""
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -7,13 +8,14 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientInfo:
|
||||
"""Client registration information."""
|
||||
"""Client registration information with RFC 7592 support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -22,12 +24,16 @@ class ClientInfo:
|
||||
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:
|
||||
@@ -41,13 +47,18 @@ class ClientInfo:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
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":
|
||||
@@ -58,6 +69,8 @@ class ClientInfo:
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +80,7 @@ async def register_client(
|
||||
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.
|
||||
@@ -77,6 +91,7 @@ async def register_client(
|
||||
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
|
||||
@@ -95,6 +110,7 @@ async def register_client(
|
||||
"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}")
|
||||
@@ -113,11 +129,24 @@ async def register_client(
|
||||
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: {client_info.get('client_secret_expires_at')} "
|
||||
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"],
|
||||
@@ -128,6 +157,8 @@ async def register_client(
|
||||
"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:
|
||||
@@ -205,13 +236,140 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
||||
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,
|
||||
force_register: bool = True,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -219,7 +377,7 @@ async def load_or_register_client(
|
||||
This function:
|
||||
1. Checks for existing client credentials in storage
|
||||
2. Validates the credentials are not expired
|
||||
3. Registers a new client if needed
|
||||
3. Registers a new client if needed (no stored credentials or expired)
|
||||
4. Saves the new client credentials
|
||||
|
||||
Args:
|
||||
@@ -228,7 +386,8 @@ async def load_or_register_client(
|
||||
storage_path: Path to store client credentials
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
force_register: Force registration even if valid credentials exist
|
||||
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
|
||||
@@ -239,11 +398,10 @@ async def load_or_register_client(
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client unless forced to register
|
||||
if not force_register:
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
# 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...")
|
||||
@@ -252,6 +410,8 @@ async def load_or_register_client(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""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)
|
||||
|
||||
|
||||
def discover_all_scopes(mcp) -> list[str]:
|
||||
"""
|
||||
Dynamically discover all OAuth scopes required by registered MCP tools.
|
||||
|
||||
This function inspects all registered tools and extracts their required scopes
|
||||
from the @require_scopes decorator metadata. It provides a single source of truth
|
||||
for available scopes based on the actual tool implementations.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP instance with registered tools
|
||||
|
||||
Returns:
|
||||
Sorted list of unique scope strings, including base OIDC scopes
|
||||
|
||||
Example:
|
||||
```python
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def get_notes():
|
||||
pass
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
scopes = discover_all_scopes(mcp)
|
||||
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
||||
```
|
||||
|
||||
Note:
|
||||
- Base OIDC scopes (openid, profile, email) are always included
|
||||
- Scopes are deduplicated and sorted alphabetically
|
||||
- Only scopes from decorated tools are included
|
||||
- Must be called after tools are registered
|
||||
"""
|
||||
# Start with base OIDC scopes that are always required
|
||||
all_scopes = {"openid", "profile", "email"}
|
||||
|
||||
# Get all registered tools
|
||||
try:
|
||||
tools = mcp._tool_manager.list_tools()
|
||||
except AttributeError:
|
||||
logger.warning("FastMCP instance does not have _tool_manager attribute")
|
||||
return sorted(all_scopes)
|
||||
|
||||
# Extract scopes from each tool
|
||||
for tool in tools:
|
||||
# Get the original function (tools have a .fn attribute)
|
||||
func = getattr(tool, "fn", None)
|
||||
if func is None:
|
||||
continue
|
||||
|
||||
# Extract scopes using existing helper
|
||||
tool_scopes = get_required_scopes(func)
|
||||
all_scopes.update(tool_scopes)
|
||||
|
||||
# Return sorted list of unique scopes
|
||||
return sorted(all_scopes)
|
||||
@@ -5,6 +5,8 @@ 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__)
|
||||
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||
|
||||
This verifier:
|
||||
1. Calls the userinfo endpoint with the bearer token
|
||||
2. Caches successful responses to avoid repeated API calls
|
||||
3. Extracts username from the 'sub' or 'preferred_username' claim
|
||||
4. Optionally supports JWT validation for performance (future enhancement)
|
||||
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
|
||||
|
||||
The userinfo endpoint validates the token and returns user claims if valid,
|
||||
or returns HTTP 400/401 if the token is invalid or expired.
|
||||
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,
|
||||
):
|
||||
"""
|
||||
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
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 requests
|
||||
# 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 by calling the userinfo endpoint.
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Calls the userinfo endpoint if not cached
|
||||
3. Returns AccessToken with username stored in metadata
|
||||
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
|
||||
@@ -69,13 +108,232 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Validate via userinfo endpoint
|
||||
# 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.
|
||||
@@ -169,15 +427,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Extract scopes from userinfo response.
|
||||
|
||||
Since the userinfo response doesn't include the original scopes,
|
||||
we infer them from the claims present in the 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 inferred scopes
|
||||
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:
|
||||
@@ -194,6 +468,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
if "groups" in userinfo:
|
||||
scopes.append("groups")
|
||||
|
||||
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||
return scopes
|
||||
|
||||
def clear_cache(self):
|
||||
|
||||
@@ -14,9 +14,13 @@ from httpx import (
|
||||
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__)
|
||||
@@ -68,9 +72,15 @@ class NextcloudClient:
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(self._client, username)
|
||||
self.calendar = CalendarClient(
|
||||
base_url, username, auth
|
||||
) # Uses AsyncDavClient internally
|
||||
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()
|
||||
@@ -113,13 +123,14 @@ class NextcloudClient:
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await 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."""
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
await self.calendar.close()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
"""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()
|
||||
@@ -99,7 +99,7 @@ class DeckClient(BaseNextcloudClient):
|
||||
permission_edit: bool,
|
||||
permission_share: bool,
|
||||
permission_manage: bool,
|
||||
) -> List[DeckACL]:
|
||||
) -> DeckACL:
|
||||
json_data = {
|
||||
"type": type,
|
||||
"participant": participant,
|
||||
@@ -107,10 +107,14 @@ 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
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return [DeckACL(**acl) for acl in response.json()]
|
||||
return DeckACL(**response.json())
|
||||
|
||||
async def update_acl_rule(
|
||||
self,
|
||||
@@ -127,13 +131,20 @@ 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
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
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}"
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def clone_board(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""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}")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
@@ -16,24 +16,22 @@ class NotesClient(BaseNextcloudClient):
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||
return response.json()
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
notes = []
|
||||
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Get all notes, yielding them one at a time."""
|
||||
cursor = ""
|
||||
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/apps/notes/api/v1/notes",
|
||||
params={"chunkSize": 50, "chunkCursor": cursor},
|
||||
params={"chunkSize": 10, "chunkCursor": cursor},
|
||||
)
|
||||
notes.extend(response.json())
|
||||
for note in response.json():
|
||||
yield note
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,223 @@
|
||||
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
|
||||
)
|
||||
@@ -570,3 +570,379 @@ 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,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import logging.config
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
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": {
|
||||
"": {
|
||||
@@ -29,9 +32,89 @@ 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.config.dictConfig(LOGGING_CONFIG)
|
||||
|
||||
|
||||
# Document Processing Configuration
|
||||
|
||||
|
||||
def get_document_processor_config() -> dict[str, Any]:
|
||||
"""Get document processor configuration from environment.
|
||||
|
||||
Returns:
|
||||
Dict with processor configs:
|
||||
{
|
||||
"enabled": bool,
|
||||
"default_processor": str,
|
||||
"processors": {
|
||||
"unstructured": {...},
|
||||
"tesseract": {...},
|
||||
"custom": {...},
|
||||
}
|
||||
}
|
||||
"""
|
||||
config: dict[str, Any] = {
|
||||
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
|
||||
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
|
||||
"processors": {},
|
||||
}
|
||||
|
||||
# Unstructured configuration
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
|
||||
config["processors"]["unstructured"] = {
|
||||
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
|
||||
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
|
||||
"languages": [
|
||||
lang.strip()
|
||||
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
|
||||
if lang.strip()
|
||||
],
|
||||
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
|
||||
}
|
||||
|
||||
# Tesseract configuration
|
||||
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
|
||||
config["processors"]["tesseract"] = {
|
||||
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
|
||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||
}
|
||||
|
||||
# Custom processor (via HTTP API)
|
||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||
if custom_url:
|
||||
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
|
||||
supported_types = {
|
||||
t.strip() for t in supported_types_str.split(",") if t.strip()
|
||||
}
|
||||
|
||||
config["processors"]["custom"] = {
|
||||
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
|
||||
"api_url": custom_url,
|
||||
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
|
||||
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
|
||||
"supported_types": supported_types,
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Controller for notes search functionality."""
|
||||
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, AsyncIterable, Dict, List
|
||||
|
||||
|
||||
class NotesSearchController:
|
||||
"""Handles notes search logic and scoring."""
|
||||
|
||||
def search_notes(
|
||||
self, notes: List[Dict[str, Any]], query: str
|
||||
async def search_notes(
|
||||
self, notes: AsyncIterable[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
|
||||
for note in notes:
|
||||
async for note in notes:
|
||||
title_tokens, content_tokens = self._process_note_content(note)
|
||||
score = self._calculate_score(query_tokens, title_tokens, content_tokens)
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Document processing plugins for extracting text from various file formats."""
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .registry import ProcessorRegistry, get_registry
|
||||
|
||||
__all__ = [
|
||||
"DocumentProcessor",
|
||||
"ProcessingResult",
|
||||
"ProcessorError",
|
||||
"ProcessorRegistry",
|
||||
"get_registry",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Abstract base class for document processing plugins."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProcessingResult(BaseModel):
|
||||
"""Standardized result from any document processor."""
|
||||
|
||||
text: str
|
||||
"""Extracted text content"""
|
||||
|
||||
metadata: dict[str, Any]
|
||||
"""Processor-specific metadata"""
|
||||
|
||||
processor: str
|
||||
"""Name of processor that handled this (e.g., 'unstructured', 'tesseract')"""
|
||||
|
||||
success: bool = True
|
||||
"""Whether processing succeeded"""
|
||||
|
||||
error: Optional[str] = None
|
||||
"""Error message if processing failed"""
|
||||
|
||||
|
||||
class DocumentProcessor(ABC):
|
||||
"""Abstract base class for document processing plugins.
|
||||
|
||||
Document processors extract text from various file formats (PDF, DOCX, images, etc.).
|
||||
Each processor implements this interface and can be registered with the ProcessorRegistry.
|
||||
|
||||
Example:
|
||||
class MyProcessor(DocumentProcessor):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "my_processor"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return {"application/pdf", "image/jpeg"}
|
||||
|
||||
async def process(self, content: bytes, content_type: str, **kwargs) -> ProcessingResult:
|
||||
# Extract text from content
|
||||
return ProcessingResult(text="...", metadata={}, processor=self.name)
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
return True
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Unique identifier for this processor (e.g., 'unstructured', 'tesseract')."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
"""Set of MIME types this processor can handle.
|
||||
|
||||
Examples: {"application/pdf", "image/jpeg", "image/png"}
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document and extract text.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type of the document
|
||||
filename: Optional filename for format detection
|
||||
options: Processor-specific options (e.g., OCR language, strategy)
|
||||
progress_callback: Optional async callback for progress updates.
|
||||
Called as: await progress_callback(progress, total, message)
|
||||
- progress: Current progress value (monotonically increasing)
|
||||
- total: Optional total value (None if unknown)
|
||||
- message: Optional human-readable status message
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if processor is available and healthy.
|
||||
|
||||
Returns:
|
||||
True if processor is ready to use, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
def supports(self, content_type: str) -> bool:
|
||||
"""Check if this processor supports the given MIME type.
|
||||
|
||||
Args:
|
||||
content_type: MIME type (may include parameters like "application/pdf; charset=utf-8")
|
||||
|
||||
Returns:
|
||||
True if this processor can handle the type
|
||||
"""
|
||||
# Strip parameters from content type
|
||||
base_type = content_type.split(";")[0].strip().lower()
|
||||
return base_type in self.supported_mime_types
|
||||
|
||||
|
||||
class ProcessorError(Exception):
|
||||
"""Raised when document processing fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Generic HTTP API processor wrapper for custom document processing services."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomHTTPProcessor(DocumentProcessor):
|
||||
"""Generic HTTP API processor wrapper.
|
||||
|
||||
Allows integration with any custom document processing API that follows
|
||||
a simple request/response pattern. This makes it easy to integrate your
|
||||
own text extraction services without writing a full processor.
|
||||
|
||||
Expected API Contract:
|
||||
- POST request with file as multipart/form-data
|
||||
- Response: {"text": "extracted text", "metadata": {...}}
|
||||
|
||||
Example:
|
||||
processor = CustomHTTPProcessor(
|
||||
name="my_ocr",
|
||||
api_url="https://my-ocr-service.com/process",
|
||||
api_key="secret",
|
||||
supported_types={"application/pdf", "image/jpeg"},
|
||||
)
|
||||
result = await processor.process(pdf_bytes, "application/pdf")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
api_key: Optional[str] = None,
|
||||
timeout: int = 60,
|
||||
supported_types: Optional[set[str]] = None,
|
||||
name: str = "custom",
|
||||
):
|
||||
"""Initialize custom HTTP processor.
|
||||
|
||||
Args:
|
||||
api_url: Your API endpoint (should accept POST with multipart/form-data)
|
||||
api_key: Optional API key for authentication (sent as Bearer token)
|
||||
timeout: Request timeout in seconds (default: 60)
|
||||
supported_types: MIME types your API supports
|
||||
name: Unique name for this processor (default: "custom")
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self._name = name
|
||||
self._supported_types = supported_types or set()
|
||||
|
||||
logger.info(f"Initialized CustomHTTPProcessor: {name} -> {api_url}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self._supported_types
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process via custom HTTP API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
options: Custom options (passed as form data to API)
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If API call fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Prepare request
|
||||
files = {"file": (filename or "document", content, content_type)}
|
||||
headers = {}
|
||||
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
files=files,
|
||||
headers=headers,
|
||||
data=options, # Pass options as form data
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
result = response.json()
|
||||
text = result.get("text", "")
|
||||
metadata = result.get("metadata", {})
|
||||
|
||||
logger.debug(
|
||||
f"Custom processor '{self.name}' extracted {len(text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Custom processor '{self.name}' HTTP error: {e}")
|
||||
raise ProcessorError(f"API call failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Custom processor '{self.name}' failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if custom API is available.
|
||||
|
||||
Returns:
|
||||
True if API responds with status < 500
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
# Try GET request to check availability
|
||||
response = await client.get(
|
||||
self.api_url,
|
||||
headers={"User-Agent": "nextcloud-mcp-server"},
|
||||
)
|
||||
return response.status_code < 500
|
||||
except Exception as e:
|
||||
logger.warning(f"Custom processor '{self.name}' health check failed: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Central registry for document processors."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessorRegistry:
|
||||
"""Central registry for document processors.
|
||||
|
||||
Manages registration and routing of document processing requests to
|
||||
appropriate processors based on MIME types and priorities.
|
||||
|
||||
Example:
|
||||
registry = ProcessorRegistry()
|
||||
registry.register(UnstructuredProcessor(...), priority=10)
|
||||
registry.register(TesseractProcessor(...), priority=5)
|
||||
|
||||
# Auto-select processor based on MIME type
|
||||
result = await registry.process(pdf_bytes, "application/pdf")
|
||||
|
||||
# Force specific processor
|
||||
result = await registry.process(img_bytes, "image/png", processor_name="tesseract")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._processors: dict[str, tuple[DocumentProcessor, int]] = {}
|
||||
self._priority_order: list[str] = []
|
||||
|
||||
def register(self, processor: DocumentProcessor, priority: int = 0):
|
||||
"""Register a document processor.
|
||||
|
||||
Args:
|
||||
processor: Processor instance to register
|
||||
priority: Higher priority processors are tried first (default: 0)
|
||||
"""
|
||||
name = processor.name
|
||||
|
||||
if name in self._processors:
|
||||
logger.warning(f"Processor '{name}' already registered, replacing")
|
||||
|
||||
self._processors[name] = (processor, priority)
|
||||
|
||||
# Update priority order
|
||||
if name in self._priority_order:
|
||||
self._priority_order.remove(name)
|
||||
|
||||
# Insert in priority order (higher priority first)
|
||||
inserted = False
|
||||
for i, existing_name in enumerate(self._priority_order):
|
||||
existing_priority = self._processors[existing_name][1]
|
||||
if priority > existing_priority:
|
||||
self._priority_order.insert(i, name)
|
||||
inserted = True
|
||||
break
|
||||
|
||||
if not inserted:
|
||||
self._priority_order.append(name)
|
||||
|
||||
logger.info(
|
||||
f"Registered processor: {name} "
|
||||
f"(priority={priority}, supports={len(processor.supported_mime_types)} types)"
|
||||
)
|
||||
|
||||
def get_processor(self, name: str) -> Optional[DocumentProcessor]:
|
||||
"""Get a processor by name.
|
||||
|
||||
Args:
|
||||
name: Processor name
|
||||
|
||||
Returns:
|
||||
DocumentProcessor instance or None if not found
|
||||
"""
|
||||
if name in self._processors:
|
||||
return self._processors[name][0]
|
||||
return None
|
||||
|
||||
def find_processor(self, content_type: str) -> Optional[DocumentProcessor]:
|
||||
"""Find the first processor that supports the given MIME type.
|
||||
|
||||
Processors are checked in priority order (highest priority first).
|
||||
|
||||
Args:
|
||||
content_type: MIME type to match
|
||||
|
||||
Returns:
|
||||
First matching processor or None
|
||||
"""
|
||||
for name in self._priority_order:
|
||||
processor = self._processors[name][0]
|
||||
if processor.supports(content_type):
|
||||
logger.debug(f"Found processor '{name}' for type '{content_type}'")
|
||||
return processor
|
||||
|
||||
logger.debug(f"No processor found for type '{content_type}'")
|
||||
return None
|
||||
|
||||
def list_processors(self) -> list[str]:
|
||||
"""List all registered processor names in priority order.
|
||||
|
||||
Returns:
|
||||
List of processor names (highest priority first)
|
||||
"""
|
||||
return list(self._priority_order)
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
processor_name: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a document using available processors.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
processor_name: Force specific processor (or None for auto-select)
|
||||
options: Processing options passed to processor
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If no processor found or processing fails
|
||||
"""
|
||||
# Find processor
|
||||
if processor_name:
|
||||
processor = self.get_processor(processor_name)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"Processor '{processor_name}' not found. "
|
||||
f"Available: {', '.join(self.list_processors())}"
|
||||
)
|
||||
else:
|
||||
processor = self.find_processor(content_type)
|
||||
if not processor:
|
||||
raise ProcessorError(
|
||||
f"No processor found for type: {content_type}. "
|
||||
f"Registered processors: {', '.join(self.list_processors())}"
|
||||
)
|
||||
|
||||
logger.info(f"Processing with '{processor.name}' processor")
|
||||
|
||||
# Process
|
||||
return await processor.process(
|
||||
content, content_type, filename, options, progress_callback
|
||||
)
|
||||
|
||||
|
||||
# Global registry instance
|
||||
_registry = ProcessorRegistry()
|
||||
|
||||
|
||||
def get_registry() -> ProcessorRegistry:
|
||||
"""Get the global processor registry.
|
||||
|
||||
Returns:
|
||||
Singleton ProcessorRegistry instance
|
||||
"""
|
||||
return _registry
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Document processor using Tesseract OCR (local)."""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
import pytesseract
|
||||
from PIL import Image
|
||||
|
||||
TESSERACT_AVAILABLE = True
|
||||
except ImportError:
|
||||
TESSERACT_AVAILABLE = False
|
||||
|
||||
|
||||
class TesseractProcessor(DocumentProcessor):
|
||||
"""Document processor using Tesseract OCR (local).
|
||||
|
||||
This processor runs OCR locally using the Tesseract engine, which is
|
||||
faster and more lightweight than cloud-based solutions but requires
|
||||
Tesseract to be installed on the system.
|
||||
|
||||
Requirements:
|
||||
- tesseract binary installed (e.g., apt install tesseract-ocr)
|
||||
- Python packages: pip install pytesseract pillow
|
||||
|
||||
Example:
|
||||
processor = TesseractProcessor(default_lang="eng+deu")
|
||||
result = await processor.process(image_bytes, "image/jpeg")
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
"image/gif",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tesseract_cmd: Optional[str] = None,
|
||||
default_lang: str = "eng",
|
||||
):
|
||||
"""Initialize Tesseract processor.
|
||||
|
||||
Args:
|
||||
tesseract_cmd: Path to tesseract executable (None = auto-detect)
|
||||
default_lang: Default OCR language (e.g., "eng", "deu", "eng+deu")
|
||||
|
||||
Raises:
|
||||
ProcessorError: If Tesseract or required packages not available
|
||||
"""
|
||||
if not TESSERACT_AVAILABLE:
|
||||
raise ProcessorError(
|
||||
"Tesseract processor requires: pip install pytesseract pillow"
|
||||
)
|
||||
|
||||
if tesseract_cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
|
||||
elif not shutil.which("tesseract"):
|
||||
raise ProcessorError(
|
||||
"Tesseract not found in PATH. Install with: apt install tesseract-ocr"
|
||||
)
|
||||
|
||||
self.default_lang = default_lang
|
||||
logger.info(f"Initialized TesseractProcessor: lang={default_lang}")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "tesseract"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process image via Tesseract OCR.
|
||||
|
||||
Args:
|
||||
content: Image bytes
|
||||
content_type: Image MIME type
|
||||
filename: Optional filename
|
||||
options: Processing options:
|
||||
- lang: OCR language(s) (default: from init)
|
||||
- config: Tesseract config string
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If OCR fails
|
||||
"""
|
||||
options = options or {}
|
||||
lang = options.get("lang", self.default_lang)
|
||||
config = options.get("config", "")
|
||||
|
||||
try:
|
||||
# Load image
|
||||
image = Image.open(io.BytesIO(content))
|
||||
|
||||
# Run OCR
|
||||
text = pytesseract.image_to_string(image, lang=lang, config=config)
|
||||
|
||||
# Get additional data for confidence scores
|
||||
data = pytesseract.image_to_data(
|
||||
image, lang=lang, output_type=pytesseract.Output.DICT
|
||||
)
|
||||
|
||||
# Calculate average confidence
|
||||
confidences = [c for c in data["conf"] if c != -1]
|
||||
avg_confidence = sum(confidences) / len(confidences) if confidences else 0
|
||||
|
||||
metadata = {
|
||||
"text_length": len(text),
|
||||
"language": lang,
|
||||
"image_size": image.size,
|
||||
"image_mode": image.mode,
|
||||
"confidence": round(avg_confidence, 2),
|
||||
"words_detected": len([c for c in data["conf"] if c != -1]),
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Tesseract OCR completed: {len(text)} chars, "
|
||||
f"confidence={avg_confidence:.1f}%"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=text.strip(),
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Tesseract processing failed: {e}")
|
||||
raise ProcessorError(f"OCR failed: {str(e)}") from e
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Tesseract is available.
|
||||
|
||||
Returns:
|
||||
True if Tesseract is installed and working
|
||||
"""
|
||||
try:
|
||||
pytesseract.get_tesseract_version()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Document processor using Unstructured.io API."""
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnstructuredProcessor(DocumentProcessor):
|
||||
"""Document processor using Unstructured.io API.
|
||||
|
||||
The Unstructured API provides document parsing capabilities for various formats
|
||||
including PDF, DOCX, images with OCR, and more.
|
||||
|
||||
API Documentation: https://docs.unstructured.io/api-reference/api-services/api-parameters
|
||||
"""
|
||||
|
||||
# Supported MIME types for Unstructured
|
||||
SUPPORTED_TYPES = {
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.ms-excel",
|
||||
"application/rtf",
|
||||
"text/rtf",
|
||||
"application/vnd.oasis.opendocument.text",
|
||||
"application/epub+zip",
|
||||
"message/rfc822",
|
||||
"application/vnd.ms-outlook",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/bmp",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_url: str,
|
||||
timeout: int = 120,
|
||||
default_strategy: str = "auto",
|
||||
default_languages: Optional[list[str]] = None,
|
||||
progress_interval: int = 10,
|
||||
):
|
||||
"""Initialize Unstructured processor.
|
||||
|
||||
Args:
|
||||
api_url: Unstructured API endpoint
|
||||
timeout: Request timeout in seconds (default: 120)
|
||||
default_strategy: Default parsing strategy - "auto", "fast", or "hi_res"
|
||||
default_languages: Default OCR language codes (e.g., ["eng", "deu"])
|
||||
progress_interval: Seconds between progress updates (default: 10)
|
||||
"""
|
||||
self.api_url = api_url
|
||||
self.timeout = timeout
|
||||
self.default_strategy = default_strategy
|
||||
self.default_languages = default_languages or ["eng"]
|
||||
self.progress_interval = progress_interval
|
||||
|
||||
logger.info(
|
||||
f"Initialized UnstructuredProcessor: {api_url}, "
|
||||
f"strategy={default_strategy}, languages={self.default_languages}, "
|
||||
f"progress_interval={progress_interval}s"
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "unstructured"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def _run_progress_poller(
|
||||
self,
|
||||
stop_event: anyio.Event,
|
||||
progress_callback: Callable[
|
||||
[float, Optional[float], Optional[str]], Awaitable[None]
|
||||
],
|
||||
start_time: float,
|
||||
):
|
||||
"""Run progress poller that reports status every N seconds.
|
||||
|
||||
Args:
|
||||
stop_event: Event to signal when processing is complete
|
||||
progress_callback: Async callback to report progress
|
||||
start_time: Time when processing started (from time.time())
|
||||
"""
|
||||
logger.debug("Starting progress poller")
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
# Wait for the event to be set, with a timeout equal to progress_interval
|
||||
with anyio.fail_after(self.progress_interval):
|
||||
await stop_event.wait()
|
||||
# If wait() finished, the event was set (processing complete)
|
||||
break
|
||||
except TimeoutError:
|
||||
# Timeout occurred - time to send a progress update
|
||||
if not stop_event.is_set(): # Double-check in case of race condition
|
||||
elapsed = int(time.time() - start_time)
|
||||
message = (
|
||||
f"Processing document with unstructured... ({elapsed}s elapsed)"
|
||||
)
|
||||
try:
|
||||
await progress_callback(
|
||||
progress=float(elapsed),
|
||||
total=None, # Unknown total duration
|
||||
message=message,
|
||||
)
|
||||
logger.debug(f"Progress update sent: {elapsed}s elapsed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send progress update: {e}")
|
||||
logger.debug("Progress poller stopped")
|
||||
|
||||
async def _make_api_request(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str],
|
||||
strategy: str,
|
||||
languages: list[str],
|
||||
extract_image_block_types: Optional[list[str]],
|
||||
) -> ProcessingResult:
|
||||
"""Make the actual API request to Unstructured.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename
|
||||
strategy: Processing strategy
|
||||
languages: OCR languages
|
||||
extract_image_block_types: Image element types to extract
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
# Prepare multipart request
|
||||
files = {
|
||||
"files": (
|
||||
filename or "document",
|
||||
io.BytesIO(content),
|
||||
content_type or "application/octet-stream",
|
||||
)
|
||||
}
|
||||
|
||||
data = {
|
||||
"strategy": strategy,
|
||||
"languages": ",".join(languages),
|
||||
}
|
||||
|
||||
if extract_image_block_types:
|
||||
data["extract_image_block_types"] = ",".join(extract_image_block_types)
|
||||
|
||||
logger.debug(
|
||||
f"Processing with Unstructured API: strategy={strategy}, languages={languages}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/general/v0/general",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
elements = response.json()
|
||||
|
||||
# Extract text and metadata
|
||||
texts = []
|
||||
element_types: dict[str, int] = {}
|
||||
|
||||
for element in elements:
|
||||
if "text" in element and element["text"]:
|
||||
texts.append(element["text"])
|
||||
|
||||
el_type = element.get("type", "unknown")
|
||||
element_types[el_type] = element_types.get(el_type, 0) + 1
|
||||
|
||||
parsed_text = "\n\n".join(texts)
|
||||
|
||||
metadata = {
|
||||
"element_count": len(elements),
|
||||
"text_length": len(parsed_text),
|
||||
"element_types": element_types,
|
||||
"strategy": strategy,
|
||||
"languages": languages,
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
f"Successfully processed: {len(elements)} elements, "
|
||||
f"{len(parsed_text)} characters"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=parsed_text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Unstructured API HTTP error: {e}")
|
||||
raise ProcessorError(f"HTTP error: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error(f"Unstructured API processing failed: {e}")
|
||||
raise ProcessorError(f"Processing failed: {str(e)}") from e
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process document via Unstructured API.
|
||||
|
||||
Args:
|
||||
content: Document bytes
|
||||
content_type: MIME type
|
||||
filename: Optional filename for format detection
|
||||
options: Processing options:
|
||||
- strategy: "auto", "fast", or "hi_res" (default: from init)
|
||||
- languages: List of language codes (default: from init)
|
||||
- extract_image_block_types: Types of image elements to extract
|
||||
progress_callback: Optional async callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If processing fails
|
||||
"""
|
||||
options = options or {}
|
||||
|
||||
# Extract options with defaults
|
||||
strategy = options.get("strategy", self.default_strategy)
|
||||
languages = options.get("languages", self.default_languages)
|
||||
extract_image_block_types = options.get("extract_image_block_types")
|
||||
|
||||
# If no progress callback, just make the request directly
|
||||
if progress_callback is None:
|
||||
return await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
|
||||
# With progress callback: run API request + progress poller concurrently
|
||||
stop_event = anyio.Event()
|
||||
start_time = time.time()
|
||||
result = None
|
||||
|
||||
async def capture_result():
|
||||
nonlocal result
|
||||
try:
|
||||
result = await self._make_api_request(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
strategy=strategy,
|
||||
languages=languages,
|
||||
extract_image_block_types=extract_image_block_types,
|
||||
)
|
||||
finally:
|
||||
# Signal poller to stop after API request completes
|
||||
stop_event.set()
|
||||
|
||||
# Run both tasks concurrently using anyio task groups
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(capture_result)
|
||||
tg.start_soon(
|
||||
self._run_progress_poller, stop_event, progress_callback, start_time
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if Unstructured API is available.
|
||||
|
||||
Returns:
|
||||
True if API is healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get(f"{self.api_url}/healthcheck")
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.warning(f"Unstructured health check failed: {e}")
|
||||
return False
|
||||
@@ -65,11 +65,14 @@ from .tables import (
|
||||
|
||||
# WebDAV models
|
||||
from .webdav import (
|
||||
CopyResourceResponse,
|
||||
CreateDirectoryResponse,
|
||||
DeleteResourceResponse,
|
||||
DirectoryListing,
|
||||
FileInfo,
|
||||
MoveResourceResponse,
|
||||
ReadFileResponse,
|
||||
SearchFilesResponse,
|
||||
WriteFileResponse,
|
||||
)
|
||||
|
||||
@@ -133,4 +136,7 @@ __all__ = [
|
||||
"WriteFileResponse",
|
||||
"CreateDirectoryResponse",
|
||||
"DeleteResourceResponse",
|
||||
"MoveResourceResponse",
|
||||
"CopyResourceResponse",
|
||||
"SearchFilesResponse",
|
||||
]
|
||||
|
||||
@@ -180,3 +180,71 @@ 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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""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
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -22,6 +22,8 @@ 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]:
|
||||
@@ -38,7 +40,7 @@ class DirectoryListing(BaseResponse):
|
||||
"""Response model for directory listings."""
|
||||
|
||||
path: str = Field(description="Directory path")
|
||||
items: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
files: 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")
|
||||
@@ -106,3 +108,14 @@ 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"
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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
|
||||
|
||||
__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",
|
||||
]
|
||||
|
||||
@@ -4,8 +4,14 @@ 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.models.calendar import Calendar, ListCalendarsResponse
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,6 +19,7 @@ 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)
|
||||
@@ -22,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
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,
|
||||
@@ -97,6 +105,7 @@ 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,
|
||||
@@ -198,6 +207,7 @@ 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,
|
||||
@@ -209,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -281,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -291,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
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,
|
||||
@@ -356,6 +369,7 @@ 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
|
||||
@@ -405,6 +419,7 @@ 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,
|
||||
@@ -484,6 +499,7 @@ 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,
|
||||
@@ -732,6 +748,7 @@ 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,
|
||||
@@ -796,3 +813,214 @@ 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))
|
||||
|
||||
@@ -2,6 +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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ 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)
|
||||
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)
|
||||
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
|
||||
):
|
||||
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
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,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@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)
|
||||
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 = ""
|
||||
):
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
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})",
|
||||
)
|
||||
)
|
||||
@@ -3,6 +3,7 @@ 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.models.deck import (
|
||||
CardOperationResponse,
|
||||
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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)
|
||||
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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)
|
||||
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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)
|
||||
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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)
|
||||
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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)
|
||||
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_board(
|
||||
ctx: Context, title: str, color: str
|
||||
) -> CreateBoardResponse:
|
||||
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_stack(
|
||||
ctx: Context, board_id: int, title: str, order: int
|
||||
) -> CreateStackResponse:
|
||||
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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,
|
||||
@@ -236,6 +248,7 @@ 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:
|
||||
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -341,6 +356,7 @@ 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:
|
||||
@@ -362,6 +378,7 @@ 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:
|
||||
@@ -383,6 +400,7 @@ 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:
|
||||
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_reorder_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -435,6 +454,7 @@ 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:
|
||||
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
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,
|
||||
@@ -475,6 +496,7 @@ 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:
|
||||
@@ -495,6 +517,7 @@ 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:
|
||||
@@ -517,6 +540,7 @@ 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:
|
||||
@@ -540,6 +564,7 @@ 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:
|
||||
@@ -562,6 +587,7 @@ 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:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
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.notes import (
|
||||
AppendContentResponse,
|
||||
@@ -61,6 +62,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
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"))
|
||||
@@ -77,10 +85,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"""
|
||||
"""Create a new note (requires notes:write scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
@@ -92,6 +101,10 @@ 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(
|
||||
@@ -118,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
@@ -126,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
"""Update an existing note's title, content, or category (requires notes:write scope).
|
||||
|
||||
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.
|
||||
@@ -146,6 +160,12 @@ 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"))
|
||||
@@ -176,6 +196,7 @@ 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:
|
||||
@@ -192,6 +213,13 @@ 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"))
|
||||
@@ -218,8 +246,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."""
|
||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
@@ -238,6 +267,10 @@ 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(
|
||||
@@ -259,12 +292,19 @@ 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"""
|
||||
"""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"))
|
||||
@@ -281,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
@@ -295,6 +336,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"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(
|
||||
@@ -319,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@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)
|
||||
@@ -330,6 +379,12 @@ 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"))
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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)
|
||||
@@ -2,6 +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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ 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)
|
||||
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)
|
||||
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,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
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.
|
||||
|
||||
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
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.
|
||||
|
||||
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
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)
|
||||
|
||||
@@ -2,7 +2,13 @@ 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.utils.document_parser import (
|
||||
is_parseable_document,
|
||||
parse_document,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,26 +16,40 @@ logger = logging.getLogger(__name__)
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_list_directory(
|
||||
ctx: Context, path: str = ""
|
||||
) -> DirectoryListing:
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
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")
|
||||
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.list_directory(path)
|
||||
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,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
@@ -37,14 +57,21 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
path: Full path to the file to read
|
||||
|
||||
Returns:
|
||||
Dict with path, content, content_type, size, and encoding (if binary)
|
||||
Text files are decoded to UTF-8, binary files are base64 encoded
|
||||
Dict with path, content, content_type, size, and optional parsing metadata
|
||||
- Text files are decoded to UTF-8
|
||||
- Documents (PDF, DOCX, etc.) are parsed and text is extracted
|
||||
- Other 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 PDF document (automatically parsed)
|
||||
result = await nc_webdav_read_file("Documents/report.pdf")
|
||||
logger.info(result['content']) # Extracted text from PDF
|
||||
logger.info(result['parsing_metadata']) # Document parsing info
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
@@ -52,6 +79,31 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
client = get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||
# is_parseable_document() checks if document processing is enabled
|
||||
if is_parseable_document(content_type):
|
||||
try:
|
||||
logger.info(f"Parsing document '{path}' of type '{content_type}'")
|
||||
parsed_text, metadata = await parse_document(
|
||||
content,
|
||||
content_type,
|
||||
filename=path,
|
||||
progress_callback=ctx.report_progress,
|
||||
)
|
||||
return {
|
||||
"path": path,
|
||||
"content": parsed_text,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"parsed": True,
|
||||
"parsing_metadata": metadata,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to parse document '{path}', falling back to base64: {e}"
|
||||
)
|
||||
# Fall through to base64 encoding on parse failure
|
||||
|
||||
# For text files, decode content for easier viewing
|
||||
if content_type and content_type.startswith("text/"):
|
||||
try:
|
||||
@@ -77,6 +129,7 @@ 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
|
||||
):
|
||||
@@ -89,13 +142,6 @@ 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)
|
||||
|
||||
@@ -111,6 +157,7 @@ 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.
|
||||
|
||||
@@ -119,18 +166,12 @@ 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)
|
||||
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.
|
||||
|
||||
@@ -139,18 +180,12 @@ 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)
|
||||
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
|
||||
):
|
||||
@@ -163,19 +198,6 @@ 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)
|
||||
return await client.webdav.move_resource(
|
||||
@@ -183,6 +205,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_copy_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -195,21 +218,202 @@ 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)
|
||||
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},
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility functions for the Nextcloud MCP server."""
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Document parsing utilities using pluggable processor registry."""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from nextcloud_mcp_server.config import get_document_processor_config
|
||||
from nextcloud_mcp_server.document_processors import (
|
||||
ProcessorError,
|
||||
get_registry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_parseable_document(content_type: Optional[str]) -> bool:
|
||||
"""Check if a document type can be parsed by any registered processor.
|
||||
|
||||
Args:
|
||||
content_type: The MIME type of the document
|
||||
|
||||
Returns:
|
||||
True if any processor can handle this type, False otherwise
|
||||
"""
|
||||
if not content_type:
|
||||
return False
|
||||
|
||||
config = get_document_processor_config()
|
||||
if not config["enabled"]:
|
||||
return False
|
||||
|
||||
registry = get_registry()
|
||||
processor = registry.find_processor(content_type)
|
||||
return processor is not None
|
||||
|
||||
|
||||
async def parse_document(
|
||||
content: bytes,
|
||||
content_type: Optional[str],
|
||||
filename: Optional[str] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> Tuple[str, dict]:
|
||||
"""Parse a document using registered processors.
|
||||
|
||||
This function uses the processor registry to find an appropriate
|
||||
processor for the given document type and extract text from it.
|
||||
|
||||
Args:
|
||||
content: The document content as bytes
|
||||
content_type: The MIME type of the document
|
||||
filename: Optional filename to help with format detection
|
||||
progress_callback: Optional async callback for progress updates during long operations
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_text, metadata) where:
|
||||
- parsed_text: The extracted text content
|
||||
- metadata: Additional metadata about the parsing
|
||||
|
||||
Raises:
|
||||
ValueError: If the document type is not supported
|
||||
Exception: If parsing fails
|
||||
"""
|
||||
if not content_type:
|
||||
raise ValueError("Content type is required for document parsing")
|
||||
|
||||
config = get_document_processor_config()
|
||||
if not config["enabled"]:
|
||||
raise ValueError("Document processing is disabled")
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
logger.debug(f"Parsing document of type '{content_type}'")
|
||||
|
||||
try:
|
||||
# Process using registry (auto-selects processor based on MIME type)
|
||||
result = await registry.process(
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
filename=filename,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
logger.info(f"Successfully parsed document with '{result.processor}' processor")
|
||||
|
||||
return result.text, result.metadata
|
||||
|
||||
except ProcessorError as e:
|
||||
logger.error(f"Document processing failed: {e}")
|
||||
# Fallback to base64 with error metadata
|
||||
parsed_text = f"Document could not be parsed. Base64 content: {base64.b64encode(content).decode('ascii')[:200]}..."
|
||||
metadata = {
|
||||
"mime_type": content_type,
|
||||
"text_length": len(parsed_text),
|
||||
"parsing_method": "fallback_base64",
|
||||
"error": str(e),
|
||||
}
|
||||
return parsed_text, metadata
|
||||
+59
-15
@@ -1,36 +1,62 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.13.0"
|
||||
description = ""
|
||||
version = "0.21.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
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.17,<1.18)",
|
||||
"mcp[cli] (>=1.19,<1.20)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"pillow (>=12.0.0,<12.1.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]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_test_loop_scope = "session"
|
||||
asyncio_default_fixture_loop_scope = "session"
|
||||
anyio_mode = "auto"
|
||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
||||
log_cli = 1
|
||||
log_cli_level = "INFO"
|
||||
log_level = "INFO"
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
"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",
|
||||
]
|
||||
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"
|
||||
@@ -40,9 +66,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 = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = "nextcloud_mcp_server"
|
||||
module-root = ""
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -50,11 +86,19 @@ dev = [
|
||||
"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",
|
||||
"reportlab>=4.0.0",
|
||||
]
|
||||
|
||||
[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
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""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__)
|
||||
+20
-48
@@ -1,4 +1,9 @@
|
||||
"""Integration tests for Calendar CalDAV operations."""
|
||||
"""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.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -15,50 +20,13 @@ logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_calendar_name():
|
||||
"""Unique calendar name for testing."""
|
||||
return f"test_calendar_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
@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."""
|
||||
"""Create a temporary event for testing and clean up afterward.
|
||||
|
||||
Uses the shared temporary_calendar fixture from conftest.py which reuses
|
||||
a session-scoped calendar to avoid Nextcloud rate limiting.
|
||||
"""
|
||||
event_uid = None
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
@@ -351,11 +319,11 @@ async def test_get_nonexistent_event(
|
||||
calendar_name = temporary_calendar
|
||||
fake_uid = f"nonexistent-{uuid.uuid4()}"
|
||||
|
||||
with pytest.raises(HTTPStatusError) as exc_info:
|
||||
# caldav library raises generic Exception for missing events, not HTTPStatusError
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
await nc_client.calendar.get_event(calendar_name, fake_uid)
|
||||
|
||||
assert exc_info.value.response.status_code == 404
|
||||
logger.info(f"Correctly got 404 for nonexistent event: {fake_uid}")
|
||||
logger.info(f"Correctly raised exception for nonexistent event: {fake_uid}")
|
||||
|
||||
|
||||
async def test_delete_nonexistent_event(
|
||||
@@ -420,7 +388,11 @@ async def test_calendar_operations_error_handling(
|
||||
# Test with non-existent calendar
|
||||
fake_calendar = f"nonexistent_calendar_{uuid.uuid4().hex}"
|
||||
|
||||
with pytest.raises(HTTPStatusError):
|
||||
await nc_client.calendar.get_calendar_events(fake_calendar)
|
||||
# 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
|
||||
|
||||
logger.info("Error handling tests completed successfully")
|
||||
+47
-57
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
"""Test that demonstrates loss of non-supported iCal fields during round-trip operations."""
|
||||
"""Test that custom iCal fields are preserved during round-trip update operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
# Create an event with standard fields
|
||||
@@ -32,7 +32,12 @@ async def test_calendar_event_custom_fields_preservation(nc_client):
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Now manually inject a custom iCal property by creating a new version with raw iCal
|
||||
# Get the calendar object from the caldav library
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
|
||||
# Now manually inject custom iCal properties into the raw data
|
||||
# This simulates what would happen if the event was created by another CalDAV client
|
||||
# with extended properties
|
||||
custom_ical = f"""BEGIN:VCALENDAR
|
||||
@@ -57,22 +62,15 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Direct CalDAV PUT to inject the custom iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=custom_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
# Update the event's raw data and save
|
||||
event.data = custom_ical
|
||||
await event.save()
|
||||
|
||||
logger.info(f"Injected custom iCal properties into event {event_uid}")
|
||||
|
||||
# Retrieve the event to confirm custom fields are present in raw iCal
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_before = response.text
|
||||
# Reload the event to confirm custom fields are present
|
||||
await event.load()
|
||||
raw_ical_before = event.data
|
||||
|
||||
logger.info("Raw iCal before update:")
|
||||
logger.info(raw_ical_before)
|
||||
@@ -93,31 +91,24 @@ END:VCALENDAR"""
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
logger.info(f"Updated event {event_uid} through MCP client")
|
||||
|
||||
# Retrieve the event again to see if custom fields survived
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
raw_ical_after = response_after.text
|
||||
# Reload the event to see if custom fields survived
|
||||
await event.load()
|
||||
raw_ical_after = event.data
|
||||
|
||||
logger.info("Raw iCal after update:")
|
||||
logger.info(raw_ical_after)
|
||||
|
||||
# THIS IS THE TEST THAT SHOULD FAIL - custom fields should be preserved but won't be
|
||||
try:
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
logger.info(
|
||||
"✓ Custom fields were preserved (unexpected - this should fail with current implementation)"
|
||||
)
|
||||
except AssertionError as e:
|
||||
logger.error(f"✗ Custom fields were lost during round-trip update: {e}")
|
||||
# Re-raise to show the test failure
|
||||
raise
|
||||
# THIS IS THE CRITICAL TEST - custom fields should be preserved
|
||||
assert (
|
||||
"X-CUSTOM-FIELD:This is a custom field that should be preserved"
|
||||
in raw_ical_after
|
||||
), "Custom field X-CUSTOM-FIELD was lost during round-trip update"
|
||||
|
||||
assert "X-VENDOR-SPECIFIC:Vendor specific data" in raw_ical_after, (
|
||||
"Custom field X-VENDOR-SPECIFIC was lost during round-trip update"
|
||||
)
|
||||
|
||||
logger.info("✓ Custom fields were preserved during update")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
@@ -299,7 +290,7 @@ END:VCARD"""
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
"""Demonstrates specific data loss scenarios in calendar events."""
|
||||
"""Test that extended iCal properties are preserved during round-trip update operations."""
|
||||
calendar_name = "personal"
|
||||
|
||||
event_data = {
|
||||
@@ -313,6 +304,11 @@ async def test_calendar_event_roundtrip_data_loss_demonstration(nc_client):
|
||||
event_uid = result["uid"]
|
||||
|
||||
try:
|
||||
# Get the calendar object and event
|
||||
calendar = nc_client.calendar._get_calendar(calendar_name)
|
||||
event = await calendar.event_by_uid(event_uid)
|
||||
await event.load()
|
||||
|
||||
# Inject additional iCal properties that are valid but not supported by our parser
|
||||
extended_ical = f"""BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
@@ -342,20 +338,13 @@ LAST-MODIFIED:{datetime.now().strftime("%Y%m%dT%H%M%SZ")}
|
||||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
# Inject the extended iCal
|
||||
event_path = f"/remote.php/dav/calendars/{nc_client.calendar.username}/{calendar_name}/{event_uid}.ics"
|
||||
await nc_client.calendar._make_request(
|
||||
"PUT",
|
||||
event_path,
|
||||
content=extended_ical,
|
||||
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||
)
|
||||
# Update the event's raw data and save
|
||||
event.data = extended_ical
|
||||
await event.save()
|
||||
|
||||
# Verify extended properties are present
|
||||
response = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
original_ical = response.text
|
||||
# Reload to verify extended properties are present
|
||||
await event.load()
|
||||
original_ical = event.data
|
||||
|
||||
# Confirm extended properties exist
|
||||
extended_properties = [
|
||||
@@ -392,11 +381,9 @@ END:VCALENDAR"""
|
||||
update_data = {"location": "Conference Room B"} # Simple location change
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, update_data)
|
||||
|
||||
# Check what survived the round-trip
|
||||
response_after = await nc_client.calendar._make_request(
|
||||
"GET", event_path, headers={"Accept": "text/calendar"}
|
||||
)
|
||||
updated_ical = response_after.text
|
||||
# Reload the event to check what survived the round-trip
|
||||
await event.load()
|
||||
updated_ical = event.data
|
||||
|
||||
logger.info("Checking which properties survived the update...")
|
||||
|
||||
@@ -423,13 +410,16 @@ END:VCALENDAR"""
|
||||
lost.append(prop)
|
||||
|
||||
logger.info(f"Properties that SURVIVED: {survived}")
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
if lost:
|
||||
logger.error(f"Properties that were LOST: {lost}")
|
||||
|
||||
# This test should fail - we expect data loss
|
||||
# Assert that all extended properties were preserved
|
||||
assert len(lost) == 0, (
|
||||
f"Round-trip update lost {len(lost)} extended properties: {lost}"
|
||||
)
|
||||
|
||||
logger.info("✓ All extended properties preserved during update")
|
||||
|
||||
finally:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
@@ -0,0 +1,498 @@
|
||||
"""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
|
||||
@@ -0,0 +1,482 @@
|
||||
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)
|
||||
@@ -0,0 +1,371 @@
|
||||
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")
|
||||
@@ -0,0 +1,511 @@
|
||||
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()
|
||||
@@ -0,0 +1,255 @@
|
||||
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
|
||||
@@ -0,0 +1,326 @@
|
||||
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
|
||||
@@ -30,7 +30,7 @@ async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient):
|
||||
|
||||
async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient):
|
||||
"""Test that OAuth client can list notes."""
|
||||
notes = await nc_oauth_client.notes.get_all_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")
|
||||
@@ -95,43 +95,9 @@ async def test_invalid_token_fails():
|
||||
# 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:
|
||||
await invalid_client.notes.get_all_notes()
|
||||
_ = [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")
|
||||
|
||||
|
||||
# OAuth MCP Integration Tests
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
import json
|
||||
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -19,36 +19,14 @@ async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
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_playwright.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 = await nc_oauth_client_playwright.notes.get_all_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")
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
import json
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client_playwright.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""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
|
||||
@@ -0,0 +1,268 @@
|
||||
"""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())}")
|
||||
+1794
-449
File diff suppressed because it is too large
Load Diff
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Integration tests for document processing with progress notifications."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestDocumentProcessingProgress:
|
||||
"""Test document processing with progress notifications."""
|
||||
|
||||
async def test_unstructured_processor_with_progress_callback(self, nc_client):
|
||||
"""Test that UnstructuredProcessor calls progress callback during processing."""
|
||||
import os
|
||||
|
||||
# Skip if unstructured is not enabled
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||
pytest.skip("Unstructured processor not enabled")
|
||||
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
# Track progress callback invocations
|
||||
progress_updates = []
|
||||
|
||||
async def track_progress(progress: float, total: float | None, message: str):
|
||||
progress_updates.append(
|
||||
{"progress": progress, "total": total, "message": message}
|
||||
)
|
||||
|
||||
# Create processor configured to use local unstructured service
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
timeout=120,
|
||||
progress_interval=2, # 2 second intervals for testing
|
||||
)
|
||||
|
||||
# Create a simple test image (which requires OCR processing)
|
||||
# This should take long enough to trigger at least one progress update
|
||||
img = Image.new("RGB", (400, 200), color=(73, 109, 137))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Process with progress callback
|
||||
result = await processor.process(
|
||||
content=test_image,
|
||||
content_type="image/png",
|
||||
filename="test.png",
|
||||
progress_callback=track_progress,
|
||||
)
|
||||
|
||||
# Verify processing succeeded
|
||||
assert result.success is True
|
||||
assert result.processor == "unstructured"
|
||||
assert isinstance(result.text, str)
|
||||
|
||||
# Note: Progress updates may or may not occur depending on processing speed
|
||||
# If updates occurred, verify their structure
|
||||
if progress_updates:
|
||||
for update in progress_updates:
|
||||
assert isinstance(update["progress"], float)
|
||||
assert update["total"] is None # Unknown total
|
||||
assert "Processing document with unstructured" in update["message"]
|
||||
assert "elapsed" in update["message"]
|
||||
|
||||
async def test_webdav_read_file_sends_progress_notifications(
|
||||
self, nc_mcp_client, nc_client
|
||||
):
|
||||
"""Test that reading a document via WebDAV MCP tool sends progress notifications."""
|
||||
import os
|
||||
|
||||
# Skip if document processing is not enabled
|
||||
if os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() != "true":
|
||||
pytest.skip("Document processing not enabled")
|
||||
|
||||
# Create a test image file in Nextcloud via WebDAV
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (400, 200), color=(100, 150, 200))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Upload test file
|
||||
test_path = "test_progress.png"
|
||||
await nc_client.webdav.write_file(test_path, test_image, "image/png")
|
||||
|
||||
try:
|
||||
# Read file via MCP tool (which should trigger document processing)
|
||||
# The MCP client will automatically track progress notifications
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": test_path}
|
||||
)
|
||||
|
||||
# Note: FastMCP progress notifications are sent automatically by ctx.report_progress
|
||||
# We can't easily capture them in this test without mocking the MCP transport layer
|
||||
# The important thing is that the code path is exercised without errors
|
||||
assert result.isError is False
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_path)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
async def test_progress_callback_not_required(self, nc_client):
|
||||
"""Test that processing works without progress callback (backward compatibility)."""
|
||||
import os
|
||||
|
||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() != "true":
|
||||
pytest.skip("Unstructured processor not enabled")
|
||||
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
processor = UnstructuredProcessor(
|
||||
api_url=os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
# Create simple test image
|
||||
img = Image.new("RGB", (200, 100), color=(50, 100, 150))
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
test_image = buffer.getvalue()
|
||||
|
||||
# Process WITHOUT progress callback
|
||||
result = await processor.process(
|
||||
content=test_image,
|
||||
content_type="image/png",
|
||||
filename="test.png",
|
||||
progress_callback=None, # Explicitly None
|
||||
)
|
||||
|
||||
# Should still work
|
||||
assert result.success is True
|
||||
assert result.processor == "unstructured"
|
||||
@@ -1,327 +0,0 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
|
||||
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}")
|
||||
@@ -1,260 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user