Compare commits

..

101 Commits

Author SHA1 Message Date
Chris Coutinho d55e5708c7 ci: fix imports 2025-10-24 01:04:30 +02:00
Chris Coutinho d4ee5a74c2 test: Update default tokens to JWT, add to introspection tests 2025-10-24 00:51:50 +02:00
Chris Coutinho 261749fcdc ci: Update oidc app 2025-10-23 22:45:22 +02:00
Chris Coutinho bdb0e17401 chore: Add logging to token introspection 2025-10-23 21:18:14 +02:00
Chris Coutinho a93e7a1e3b build: Update submodule 2025-10-23 16:56:18 +02:00
Chris Coutinho f2d2dd8068 feat: Enable token introspection for opaque tokens 2025-10-23 15:51:27 +02:00
Chris Coutinho d915efd3f6 docs: Update jwt docs [skip ci] 2025-10-23 15:26:51 +02:00
Chris Coutinho 053cf7798b fix: Add CORS middleware to allow browser-based clients like MCP Inspector 2025-10-23 15:23:41 +02:00
github-actions[bot] 87c6f077f3 bump: version 0.17.1 → 0.18.0 2025-10-23 10:23:48 +00:00
Chris Coutinho 38e12db46a Merge pull request #233 from cbcoutinho/feature/jwt-scopes
feat: Initialize JWT-scoped tools
2025-10-23 12:23:12 +02:00
Chris Coutinho 1a7ce5b7a7 docs: Update jwt docs [skip ci] 2025-10-23 12:22:34 +02:00
Chris Coutinho 737780b417 chore: Make all env vars available to be overriden as cli options 2025-10-23 11:48:01 +02:00
Chris Coutinho b4039e2e40 docs: Update jwt docs 2025-10-23 11:20:49 +02:00
Chris Coutinho 54e975198f test: Update all test network hosts to respect iss claims from JWTs 2025-10-23 11:09:51 +02:00
Chris Coutinho e9a16c43b5 refactor: Update JWT client to use DCR, re-enable tool filtering 2025-10-23 09:33:06 +02:00
Chris Coutinho e48f5f3f30 feat(server): Add support for custom OIDC scopes and permissions via JWTs 2025-10-23 08:37:36 +02:00
Chris Coutinho 3ebc468a09 ci: Tasks has been updated, no longer a debug app 2025-10-23 07:53:52 +02:00
Chris Coutinho 1aecb099e6 fix: Use occ-created OAuth clients with allowed_scopes for all tests
The shared_oauth_client_credentials fixture was using Dynamic Client
Registration which doesn't support Nextcloud's allowed_scopes parameter.
This caused tokens to lack proper scope configuration, resulting in empty
tool lists when the server validated scopes.

Changes:
1. Updated shared_oauth_client_credentials to use occ oidc:create with
   allowed_scopes="openid profile email nc:read nc:write"
2. Created opaque token client (not JWT) for port 8001 compatibility
3. Enhanced _create_oauth_client_with_scopes to support both JWT and
   opaque token types via token_type parameter

This ensures:
- Regular OAuth tests (port 8001) get opaque tokens with proper scopes
- JWT OAuth tests (port 8002) get JWT tokens with embedded scopes
- Both token types have allowed_scopes configured on the OAuth client

Fixes test_mcp_oauth_server_connection which was getting empty tool list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 07:38:16 +02:00
Chris Coutinho 2c35e07675 fix: Separate OAuth fixtures for opaque vs JWT tokens
Previous fix created a JWT OAuth client for all tests, which broke the
regular OAuth server (port 8001) that expects opaque tokens.

This commit:
1. Reverts shared_oauth_client_credentials to use regular OAuth (opaque tokens)
2. Creates new shared_jwt_oauth_client_credentials for JWT OAuth clients
3. Creates new playwright_oauth_token_jwt fixture using JWT credentials
4. Updates nc_mcp_oauth_jwt_client to use JWT token fixture

This ensures:
- Regular OAuth tests (port 8001) use opaque tokens
- JWT OAuth tests (port 8002) use JWT tokens with embedded scopes

Fixes remaining CI failure in test_mcp_oauth_server_connection
2025-10-22 07:17:43 +02:00
Chris Coutinho 5cfdff0faf test: Create JWT OAuth client with explicit scopes for shared test fixture
The shared_oauth_client_credentials fixture was creating an OAuth client
without explicit allowed_scopes configuration. This caused JWT tokens to
lack nc:read and nc:write scope claims, resulting in the JWT MCP server
filtering out ALL tools when list_tools() was called.

Changed the fixture to use _create_oauth_client_with_scopes() helper to
create a JWT client with explicit allowed_scopes="openid profile email
nc:read nc:write", matching the scopes requested in the authorization
URL and the behavior of other scoped test fixtures.

This fixes CI test failures in:
- test_mcp_oauth.py::test_mcp_oauth_server_connection
- test_mcp_oauth_jwt.py::test_jwt_mcp_server_connection
- test_mcp_oauth_jwt.py::test_jwt_tool_list_operations
- test_mcp_oauth_jwt.py::test_jwt_automation_worked

All were failing with: assert len(result.tools) > 0 (result.tools was empty)
2025-10-22 07:02:40 +02:00
Chris Coutinho eb7e15cac0 Merge pull request #232 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f9bec5c
2025-10-22 06:42:22 +02:00
Chris Coutinho 894723c525 ci: Add missing files 2025-10-22 06:40:11 +02:00
Chris Coutinho 8a3269f366 test: Use separate docker compose command 2025-10-22 06:38:05 +02:00
Chris Coutinho c069d78f80 feat: Initialize JWT-scoped tools 2025-10-22 06:21:16 +02:00
renovate-bot-cbcoutinho[bot] e3436fecc0 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to f9bec5c 2025-10-22 04:06:24 +00:00
Chris Coutinho e3feb3eb2f Merge pull request #231 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.5
2025-10-22 03:59:07 +02:00
renovate-bot-cbcoutinho[bot] eedaa2e3f1 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.5 2025-10-21 22:09:23 +00:00
Chris Coutinho d517fe09d8 Merge pull request #230 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to d3d8b9d
2025-10-21 23:24:50 +02:00
renovate-bot-cbcoutinho[bot] 08ebab9f48 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to d3d8b9d 2025-10-21 16:06:08 +00:00
Chris Coutinho f4f9548681 Merge pull request #229 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f
2025-10-21 13:45:08 +02:00
renovate-bot-cbcoutinho[bot] 27bb0a4b56 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f 2025-10-21 10:06:57 +00:00
Chris Coutinho 7f5828390c docs: Update README 2025-10-21 11:47:01 +02:00
Chris Coutinho 8ad1937347 docs: Update README 2025-10-21 11:26:11 +02:00
Chris Coutinho 0d29048155 Merge pull request #228 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7
2025-10-21 00:10:27 +02:00
Chris Coutinho 499429706c Merge branch 'master' into renovate/astral-sh-setup-uv-7.x 2025-10-21 00:09:50 +02:00
Chris Coutinho 2903094d67 Merge pull request #227 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-10-21 00:09:12 +02:00
renovate-bot-cbcoutinho[bot] 7abfa19d15 chore(deps): update astral-sh/setup-uv action to v7 2025-10-20 22:06:35 +00:00
renovate-bot-cbcoutinho[bot] c109626601 chore(deps): pin dependencies 2025-10-20 22:06:30 +00:00
Chris Coutinho a5a4e809c4 ci: Add smoke test during release 2025-10-20 23:39:47 +02:00
github-actions[bot] 4984496d81 bump: version 0.17.0 → 0.17.1 2025-10-20 21:16:09 +00:00
Chris Coutinho 0e79ba06a9 Merge pull request #226 from cbcoutinho/feature/docs
Feature/docs
2025-10-20 23:15:20 +02:00
Chris Coutinho 48744e8a6c ci: Publish to PyPI 2025-10-20 23:14:12 +02:00
Chris Coutinho 63b898c0e3 chore: Update logs 2025-10-20 22:57:18 +02:00
Chris Coutinho e8f1340133 fix(caldav): Fix caldav search() due to missing todos 2025-10-20 22:18:46 +02:00
Chris Coutinho fde68dac55 ci: Enable publish to test pypi 2025-10-20 20:27:01 +02:00
Chris Coutinho 460e2e190c ci: set workflow to be on workflow_dispatch 2025-10-20 20:22:07 +02:00
Chris Coutinho 989b6de3c0 build: Switch to uv build backend 2025-10-20 20:10:57 +02:00
Chris Coutinho aa0b6dc5dd docs: Update docs 2025-10-20 19:10:23 +02:00
Chris Coutinho 7ae78d3a39 Merge pull request #225 from cbcoutinho/feature/oidc-bump
Remove patch for OIDC app
2025-10-20 16:02:37 +02:00
Chris Coutinho 54326f9c64 Remove patch for OIDC app 2025-10-20 15:50:11 +02:00
Chris Coutinho 6ba87e7e05 chore: update caldav ref 2025-10-20 11:52:29 +02:00
github-actions[bot] 45bbf97033 bump: version 0.16.0 → 0.17.0 2025-10-19 22:55:23 +00:00
Chris Coutinho 14a0f166fe Merge pull request #223 from cbcoutinho/feature/caldav
Migrate to caldav and add support for VTODOs
2025-10-20 00:54:51 +02:00
Chris Coutinho 71f09a47ca docs: Update CalendarClient docstrings [skip ci] 2025-10-20 00:54:35 +02:00
Chris Coutinho 61bb8cc048 Merge pull request #224 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.1
2025-10-20 00:15:05 +02:00
renovate-bot-cbcoutinho[bot] ad9b9f25a1 chore(deps): update astral-sh/setup-uv action to v7.1.1 2025-10-19 22:05:34 +00:00
Chris Coutinho f4dd68735c test: Fix how categories are handled in calendar 2025-10-20 00:04:38 +02:00
Chris Coutinho c75f0c0a17 test: Revert creation 2025-10-19 23:59:07 +02:00
Chris Coutinho a143123acc fix(caldav): Check that calendar exists after creation to avoid race condition
Verify that field preservation tests still operate
2025-10-19 23:44:39 +02:00
Chris Coutinho 1dc2ddfdb7 fix(caldav): Properly parse datetimes as vDDDTypes 2025-10-19 20:13:05 +02:00
Chris Coutinho 92e18825bc feat(caldav): Add support for tasks 2025-10-19 18:02:43 +02:00
Chris Coutinho d398a8c8e6 refactor: Migrate from internal CalendarClient to caldav library 2025-10-19 15:47:17 +02:00
Chris Coutinho 39dfa13895 docs: Remove user API docs 2025-10-19 14:06:14 +02:00
github-actions[bot] cb7a609ec2 bump: version 0.15.2 → 0.16.0 2025-10-19 00:13:49 +00:00
Chris Coutinho b8d241b596 Merge pull request #219 from cbcoutinho/feature/load-testing
Feature/load testing
2025-10-19 02:13:18 +02:00
Chris Coutinho 5395f8d3d6 chore: Update lock file 2025-10-19 02:02:05 +02:00
Chris Coutinho 198d7495f0 ci: Remove --setup-show from pytest args 2025-10-19 01:58:22 +02:00
Chris Coutinho c2f6c6ce0d ci: Set cookbook recipe import timeout to 5min 2025-10-19 01:49:21 +02:00
Chris Coutinho 5757f2582b ci: Run oauth tests 2025-10-19 00:49:55 +02:00
Chris Coutinho d5e6411c45 test: disable asyncio fixture 2025-10-19 00:49:24 +02:00
Chris Coutinho f0c03ceede Merge pull request #221 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.9.4-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 1a51c77
2025-10-19 00:28:59 +02:00
Chris Coutinho 7818eb104e ci: Add --setup-show to pytest 2025-10-19 00:28:28 +02:00
Chris Coutinho b72514bb32 ci: Add pytest-timeout to dev deps 2025-10-19 00:27:19 +02:00
renovate-bot-cbcoutinho[bot] f51d3a2101 chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 1a51c77 2025-10-18 22:07:46 +00:00
Chris Coutinho 5de4055f9f ci: Set log level INFO 2025-10-19 00:05:00 +02:00
Chris Coutinho 95da43ea0f ci: Increase playwright timeout to 60s 2025-10-18 23:26:50 +02:00
Chris Coutinho ae47c5f3e6 ci: Use chromium 2025-10-18 23:12:53 +02:00
Chris Coutinho 31ffeba69b chore: Move timeout to recipe import 2025-10-18 23:12:31 +02:00
Chris Coutinho 963a504ae2 ci: Replace 0.5 stagger with 10s in CI 2025-10-18 22:57:47 +02:00
Chris Coutinho ead298c132 chore: revert conftest.py 2025-10-18 22:44:51 +02:00
Chris Coutinho 2f805e54b7 test: Migrate load test benchmark scripts to anyio
Remove unused redis container
2025-10-18 22:40:50 +02:00
Chris Coutinho 6158a890af feat(webdav): Add search and list favorite response tools 2025-10-18 22:02:26 +02:00
Chris Coutinho 240ceb3808 test: Migrate load test framework to anyio as well 2025-10-18 22:02:25 +02:00
Chris Coutinho 1459fe9bc8 test: Replace pytest-asyncio plugin fixtures with anyio fixtures 2025-10-18 22:02:25 +02:00
Chris Coutinho 37164dbdbc chore: sort imports 2025-10-18 22:02:25 +02:00
Chris Coutinho c3ff92a8c1 test: Cleanup testing fixtures regarding canceled scopes 2025-10-18 22:02:25 +02:00
Chris Coutinho 371d0c93a5 test: Update oauth benchmark tests 2025-10-18 22:02:25 +02:00
Chris Coutinho 644c59bf78 docs: remove old docs 2025-10-18 22:02:25 +02:00
Chris Coutinho 056b6fc9d6 test: Initialize load testing framework 2025-10-18 22:02:24 +02:00
Chris Coutinho 83917b3786 perf(notes): Improve notes search performance using async iterators 2025-10-18 22:02:19 +02:00
Chris Coutinho 955ad78f13 test: Add load testing framework 2025-10-18 22:02:19 +02:00
Chris Coutinho 3f04449a86 Merge pull request #220 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.9.4-python3.11-alpine
chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 4992e5c
2025-10-18 18:31:01 +02:00
renovate-bot-cbcoutinho[bot] 144a54c1ad chore(deps): update ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine docker digest to 4992e5c 2025-10-18 16:08:33 +00:00
Chris Coutinho 90b4b2a038 Merge pull request #218 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.4
2025-10-18 12:41:19 +02:00
renovate-bot-cbcoutinho[bot] cdfab26c75 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.4 2025-10-18 04:07:22 +00:00
github-actions[bot] a389f2940e bump: version 0.15.1 → 0.15.2 2025-10-17 23:17:32 +00:00
Chris Coutinho 5e829fc7e7 refactor: Unify logging & remove factory deployment 2025-10-18 01:15:06 +02:00
Chris Coutinho 9c909b6e42 Merge pull request #217 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin docker.io/library/nginx docker tag to 61e0128
2025-10-17 09:21:50 +02:00
renovate-bot-cbcoutinho[bot] 9b29eabfaa chore(deps): pin docker.io/library/nginx docker tag to 61e0128 2025-10-17 04:07:05 +00:00
github-actions[bot] 7549c988f4 bump: version 0.15.0 → 0.15.1 2025-10-17 02:49:37 +00:00
Chris Coutinho 0145be4bbd Merge pull request #216 from cbcoutinho/feature/trigger
Fix timeouts (in CI)
2025-10-17 04:49:17 +02:00
90 changed files with 14163 additions and 2284 deletions
+33
View File
@@ -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
+23 -4
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@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@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
+1 -1
View File
@@ -6,4 +6,4 @@ __pycache__/
.env.*.local
# Generated by pytest used to login users
.nextcloud_oauth_shared_test_client.json
.nextcloud_oauth_*.json
+6
View File
@@ -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
+60
View File
@@ -1,3 +1,63 @@
## 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
+91 -4
View File
@@ -19,6 +19,53 @@ uv run pytest --cov
uv run pytest -m "not integration"
```
### Load Testing
```bash
# Run benchmark with default settings (10 workers, 30 seconds)
uv run python -m tests.load.benchmark
# Quick test with custom concurrency and duration
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
# Extended load test (50 workers for 5 minutes)
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON for analysis
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
# Test OAuth server on port 8001
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
# Verbose mode with detailed logging
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
```
**Load Testing Features:**
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
- **Real-time progress** bar with live RPS and error counts
- **Detailed metrics**:
- Throughput (requests/second)
- Latency percentiles (p50, p90, p95, p99)
- Per-operation breakdown
- Error rates and types
- **Automatic cleanup** of test data
- **JSON export** for CI/CD integration
- **Server health checks** before starting
**Understanding Results:**
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
- **Latency**:
- p50 (median): Should be <100ms for most operations
- p95: Should be <500ms
- p99: Should be <1000ms
- **Error Rate**: Should be <1% under normal load
**Common Bottlenecks:**
1. Nextcloud backend API response times (most common)
2. Database connection limits
3. HTTP client connection pooling
4. Network I/O between containers
### Code Quality
```bash
# Format and lint code
@@ -89,7 +136,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
@@ -102,9 +159,27 @@ 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/` and `tests/client/`, `tests/server/` - Test real Nextcloud API interactions
- **Integration tests** in `tests/client/` and `tests/server/` - 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:
@@ -126,8 +201,8 @@ Each Nextcloud app has a corresponding server module that:
- `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
@@ -177,3 +252,15 @@ uv run pytest tests/server/test_oauth*.py -v
- **`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
View File
@@ -1,4 +1,7 @@
FROM ghcr.io/astral-sh/uv:0.9.3-python3.11-alpine@sha256:c5c8e9241027c384aa5e0d0368a6fd013945a23b7a5f25c754ed55ea7ef64f92
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
+34 -20
View File
@@ -7,22 +7,35 @@
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.
> [!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 endpoint for external LLMs. 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. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
> **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.
## Features
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
### Supported Nextcloud Apps
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|--------|---------------------------------------------|--------------------------------------------------------|
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
| App | Support | 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. |
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
| **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) |
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!
@@ -30,14 +43,15 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
| Mode | Security | Best For |
|------|----------|----------|
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) |
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
| **Basic Auth** ✅ | Lower | Development, testing, production |
> [!IMPORTANT]
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
> **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
> - **Production use**: Wait for upstream patches to be merged into official releases
> - **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.
@@ -92,10 +106,10 @@ See [Configuration Guide](docs/configuration.md) for all options.
3. Start the server
**OAuth Setup (experimental):**
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
3. Enable dynamic client registration
4. Configure Bearer token validation
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.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
@@ -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);
@@ -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
+38
View File
@@ -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
View File
@@ -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"
+33 -5
View File
@@ -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.0@sha256:f9bec5c77a8d5603354b990550a4d24487deae6e589dd20ce870e43e28460e18
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,9 +42,10 @@ services:
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
recipes:
image: docker.io/library/nginx:alpine
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
@@ -70,13 +74,37 @@ services:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1: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 nc:read nc:write
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
volumes:
- oauth-client-storage:/app/.oauth
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
# Client will be registered with token_type=JWT on first startup
volumes:
- oauth-jwt-client-storage:/app/.oauth-jwt
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-jwt-client-storage:
+901
View File
@@ -0,0 +1,901 @@
# 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** - `nc:read` and `nc: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 |
|-------|-------------|------------|
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
| `nc: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 nc:read nc: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 |
|-------|------------|----------|
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `nc: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 nc:read nc:write
```
**Read-Only:**
```
openid profile email nc:read
```
**No Custom Scopes (OIDC only):**
```
openid profile email
```
### Implementation
All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires nc:read scope)"""
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires nc:write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
- ✅ 54 write tools decorated with `@require_scopes("nc: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 `nc:read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**Token with `nc: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 `nc:read` or `nc: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="nc: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:8002/mcp",
"scopes_supported": ["nc:read", "nc: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 three MCP server variants:
| Service | Port | Auth Type | Token Type | Use Case |
|---------|------|-----------|------------|----------|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
### JWT Service Configuration
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
**Default Configuration (DCR):**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
volumes:
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
```
**With Pre-Configured Credentials:**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- 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
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
```
**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** - Set `TOKEN_TYPE=jwt` for better performance
- **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 nc:read nc: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 nc:read nc: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 nc:read nc: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 nc:read nc: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 nc:read nc: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 `["nc:read", "nc: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 nc:read nc: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 `nc:read` or `nc: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 `nc: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 `nc: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 `nc:read` and `nc: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 `nc:read` only
- `write_only_oauth_client_credentials` - Client with `nc: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 `nc:read`
- `playwright_oauth_token_write_only` - Obtains token with `nc: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 nc:read nc:write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `nc: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-jwt | grep "OAuth mode"
# Should see: "Running in OAuth mode"
# If not, check environment variables:
docker compose exec mcp-oauth-jwt 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 nc:read nc: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-jwt | grep "WWW-Authenticate scope challenges enabled"
# Should see this during startup
# Check exception handling
docker compose logs mcp-oauth-jwt | 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-jwt | grep -E "JWT|scope|tool"
# Check for issuer mismatches
docker compose logs mcp-oauth-jwt | 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-jwt:
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
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 nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
ports:
- "8002:8002"
```
### 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', 'nc:read', 'nc:write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: nc:write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc: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
+2 -6
View File
@@ -296,8 +296,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:
@@ -306,10 +305,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
+43 -27
View File
@@ -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
@@ -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.
+412
View File
@@ -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-jwt container will trigger the OAuth flow
docker compose logs -f mcp-oauth-jwt
```
**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:8002/oauth/callback&scope=openid+profile+email+nc:read+nc: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)
- ✓ nc:read (custom scope, shown as-is)
- ✓ nc: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
+366 -104
View File
@@ -5,16 +5,25 @@ 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,
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, setup_logging
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
from nextcloud_mcp_server.server import (
configure_calendar_tools,
@@ -134,6 +143,95 @@ 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
scopes = os.getenv(
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
)
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]:
"""
@@ -176,8 +274,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()
@@ -188,45 +284,24 @@ 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")
@@ -266,8 +341,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()
@@ -281,59 +354,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")
@@ -352,9 +426,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Asynchronously get the OAuth configuration
import asyncio
nextcloud_host, token_verifier, auth_settings = asyncio.run(
setup_oauth_config()
)
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_oauth,
@@ -398,6 +470,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
@@ -410,7 +531,96 @@ 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."""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
resource_url = f"{mcp_server_url}/mcp"
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
return JSONResponse(
{
"resource": resource_url,
"scopes_supported": ["openid", "nc:read", "nc:write"],
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
)
# Register PRM endpoint at both path-based and root locations per RFC 9728
# Path-based discovery: /.well-known/oauth-protected-resource{path}
routes.append(
Route(
"/.well-known/oauth-protected-resource/mcp",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
# Root discovery (fallback): /.well-known/oauth-protected-resource
routes.append(
Route(
"/.well-known/oauth-protected-resource",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
logger.info(
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan)
# Add CORS middleware to allow browser-based clients like MCP Inspector
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Allow all origins for development
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"],
)
# 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
@@ -422,10 +632,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",
@@ -480,11 +686,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 nc:read nc:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@click.option(
"--oauth-token-type",
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
default="bearer",
show_default=True,
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
)
@click.option(
"--public-issuer-url",
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
)
def run(
host: str,
port: int,
workers: int,
reload: bool,
log_level: str,
transport: str,
enable_app: tuple[str, ...],
@@ -493,6 +732,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.
@@ -504,24 +749,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 nc:read" --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:
@@ -591,21 +864,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
)
+18
View File
@@ -3,6 +3,16 @@
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
ScopeAuthorizationError,
check_scopes,
get_access_token_scopes,
get_required_scopes,
has_required_scopes,
is_jwt_token,
require_scopes,
)
from .token_verifier import NextcloudTokenVerifier
__all__ = [
@@ -11,4 +21,12 @@ __all__ = [
"register_client",
"load_or_register_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
"InsufficientScopeError",
"check_scopes",
"get_access_token_scopes",
"get_required_scopes",
"has_required_scopes",
"is_jwt_token",
]
@@ -1,5 +1,6 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
@@ -67,6 +68,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 +79,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 +98,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,8 +117,11 @@ 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)"
)
@@ -211,6 +218,8 @@ async def load_or_register_client(
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
@@ -227,6 +236,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
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
@@ -249,6 +260,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,278 @@
"""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., "nc:read", "nc:write")
Returns:
Decorated function that checks scopes before execution
Example:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the nc:read scope
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the nc: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, "nc:read", "nc: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("nc:read", "nc:write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["nc:read", "nc: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("nc:write")
async def create_note():
pass
user_scopes = {"nc:read", "nc:write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"nc: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)
+291 -16
View File
@@ -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):
+8 -9
View File
@@ -9,7 +9,6 @@ from httpx import (
BasicAuth,
Request,
Response,
Timeout,
)
from ..controllers.notes_search import NotesSearchController
@@ -21,8 +20,8 @@ from .groups import GroupsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
from .webdav import WebDAVClient
from .users import UsersClient
from .webdav import WebDAVClient
logger = logging.getLogger(__name__)
@@ -67,16 +66,15 @@ class NextcloudClient:
auth=auth,
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(
30.0
), # 30 second timeout for all operations including recipe imports
)
# Initialize app clients
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)
@@ -125,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
+6 -1
View File
@@ -3,6 +3,8 @@
import logging
from typing import Any, Dict, List
from httpx import Timeout
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
@@ -127,7 +129,10 @@ class CookbookClient(BaseNextcloudClient):
"""
logger.info(f"Importing recipe from URL: {url}")
response = await self._make_request(
"POST", "/apps/cookbook/api/v1/import", json={"url": url}
"POST",
"/apps/cookbook/api/v1/import",
json={"url": url},
timeout=Timeout(300.0),
)
return response.json()
+6 -8
View File
@@ -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(
+2 -1
View File
@@ -1,4 +1,5 @@
from typing import List, Optional, Dict
from typing import Dict, List, Optional
from nextcloud_mcp_server.client.base import BaseNextcloudClient
from nextcloud_mcp_server.models.users import UserDetails
+376
View File
@@ -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,
)
+18 -2
View File
@@ -2,17 +2,18 @@ import logging.config
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "http",
}
},
},
"formatters": {
"http": {
"format": "%(levelname)s [%(asctime)s] %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
}
},
},
"loggers": {
"": {
@@ -29,6 +30,21 @@ 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,
},
},
}
@@ -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)
+6
View File
@@ -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",
]
+68
View File
@@ -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"
)
+1
View File
@@ -1,4 +1,5 @@
from typing import Any, Dict, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field
+13
View File
@@ -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]:
@@ -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"
)
+229 -1
View File
@@ -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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc:write")
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("nc:write")
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("nc:write")
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("nc:read")
async def nc_calendar_search_todos(
ctx: Context,
status: Optional[str] = None,
min_priority: Optional[int] = None,
categories: Optional[str] = None,
summary_contains: Optional[str] = None,
):
"""Search todos across all calendars with optional filtering.
Args:
ctx: MCP context
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
min_priority: Filter by minimum priority (1=highest, 9=lowest)
categories: Filter by categories (comma-separated, e.g., "work,urgent")
summary_contains: Filter todos where summary contains this text
Returns:
List of todos matching the filters from all calendars
"""
client = get_client(ctx)
# Build filters dictionary
filters = {}
if status is not None:
filters["status"] = status
if min_priority is not None:
filters["min_priority"] = min_priority
if categories is not None:
filters["categories"] = [cat.strip() for cat in categories.split(",")]
if summary_contains is not None:
filters["summary_contains"] = summary_contains
todos_data = await client.calendar.search_todos_across_calendars(
filters if filters else None
)
todos = [Todo(**todo_data) for todo_data in todos_data]
return ListTodosResponse(todos=todos, total_count=len(todos))
+8
View File
@@ -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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc:write")
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
+14
View File
@@ -5,6 +5,7 @@ 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,
@@ -70,6 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
@@ -126,6 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
@@ -150,6 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
@@ -174,6 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
@@ -252,6 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
@@ -340,6 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
@@ -374,6 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
@@ -409,6 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
@@ -435,6 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
@@ -470,6 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
@@ -494,6 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
@@ -527,6 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
@@ -569,6 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
+26
View File
@@ -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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc: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("nc:write")
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
+12 -4
View File
@@ -5,6 +5,7 @@ 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,
@@ -84,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc: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 nc:write scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.create_note(
@@ -129,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_update_note(
note_id: int,
etag: str,
@@ -137,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 nc: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.
@@ -193,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
@@ -242,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc: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 nc:read scope)."""
client = get_client(ctx)
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -287,8 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc: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 nc:read scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
@@ -315,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
@@ -360,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
+8 -1
View File
@@ -2,9 +2,11 @@
import json
from nextcloud_mcp_server.context import get_client
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.
@@ -14,6 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
"""
@mcp.tool()
@require_scopes("nc:write")
async def nc_share_create(
path: str,
share_with: str,
@@ -52,6 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("nc:write")
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
@@ -70,6 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
@@ -87,6 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(share_data, indent=2)
@mcp.tool()
@require_scopes("nc:write")
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
) -> str:
@@ -107,6 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
return json.dumps(shares, indent=2)
@mcp.tool()
@require_scopes("nc:write")
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
+7
View File
@@ -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("nc: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("nc: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("nc: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("nc: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("nc: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("nc:write")
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
+203 -63
View File
@@ -2,7 +2,9 @@ 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 FileInfo, SearchFilesResponse
logger = logging.getLogger(__name__)
@@ -10,6 +12,7 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
"""List files and directories in the specified NextCloud path.
@@ -18,18 +21,12 @@ def configure_webdav_tools(mcp: FastMCP):
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")
"""
client = get_client(ctx)
return await client.webdav.list_directory(path)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -39,15 +36,6 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with path, content, content_type, size, and encoding (if binary)
Text files are decoded to UTF-8, binary files are base64 encoded
Examples:
# Read a text file
result = await nc_webdav_read_file("Documents/readme.txt")
logger.info(result['content']) # Decoded text content
# Read a binary file
result = await nc_webdav_read_file("Images/photo.jpg")
logger.info(result['encoding']) # 'base64'
"""
client = get_client(ctx)
content, content_type = await client.webdav.read_file(path)
@@ -77,6 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
):
@@ -89,13 +78,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 +93,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -119,18 +102,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("nc:write")
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -139,18 +116,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("nc:write")
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -163,19 +134,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 +141,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -195,21 +154,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("nc: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("nc: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("nc: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("nc: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},
)
+51 -11
View File
@@ -1,12 +1,14 @@
[project]
name = "nextcloud-mcp-server"
version = "0.15.0"
description = ""
version = "0.18.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.18,<1.19)",
"httpx (>=0.28.1,<0.29.0)",
@@ -15,15 +17,34 @@ dependencies = [
"pythonvcard4>=0.2.0",
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
]
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 = "WARN"
log_level = "WARN"
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\"')"
@@ -31,6 +52,9 @@ markers = [
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 +64,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 +84,17 @@ dev = [
"ipython>=9.2.0",
"playwright>=1.49.1",
"pytest>=8.3.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-playwright-asyncio>=0.7.1",
"pytest-timeout>=2.3.1",
"ruff>=0.11.13",
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.app:run"
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
+307
View File
@@ -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()
+232
View File
@@ -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()
View File
View File
+11
View File
@@ -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__)
@@ -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")
@@ -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
+2 -2
View File
@@ -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,7 +95,7 @@ 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
+1 -1
View File
@@ -27,6 +27,6 @@ async def test_oauth_client_with_playwright_flow(nc_oauth_client):
logger.info("OAuth client (Playwright) successfully fetched capabilities")
# Test 2: 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 (Playwright) successfully listed {len(notes)} notes")
+3 -3
View File
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_create_and_delete_share(nc_client):
"""Test creating and deleting a file share."""
# Create a test user to share with
@@ -68,7 +68,7 @@ async def test_create_and_delete_share(nc_client):
pass
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_update_share_permissions(nc_client):
"""Test updating share permissions."""
# Create a test user to share with
@@ -120,7 +120,7 @@ async def test_update_share_permissions(nc_client):
pass
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_list_shares(nc_client):
"""Test listing all shares."""
# Create a test user to share with
+268
View File
@@ -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())}")
+1067 -104
View File
File diff suppressed because it is too large Load Diff
+534
View File
@@ -0,0 +1,534 @@
# OAuth Multi-User Load Testing Framework
Comprehensive multi-user benchmarking system for testing OAuth-authenticated Nextcloud MCP server with realistic collaborative workflows.
## Quick Start
```bash
# 1. Ensure docker-compose is running
docker-compose up -d
# 2. Run a benchmark with 2 users for 30 seconds
uv run python -m tests.load.oauth_benchmark --users 2 --duration 30
# 3. Clean up test users (IMPORTANT - always run after benchmark)
uv run python -m tests.load.cleanup_loadtest_users
# Optional: Verify cleanup
uv run python -m tests.load.cleanup_loadtest_users --dry-run
```
## Overview
This framework extends the basic load testing infrastructure to support:
- **Multiple OAuth-authenticated users** running concurrently
- **Coordinated workflows** spanning multiple users (sharing, collaboration, permissions)
- **Per-user metrics** tracking individual user performance
- **Workflow-specific metrics** measuring cross-user operation latencies
- **Realistic scenarios** mimicking actual user collaboration patterns
- **Concurrent user creation** - all users created and authenticated in parallel for fast setup
## Architecture
### Components
```
tests/load/
├── oauth_pool.py # OAuth user pool management
├── oauth_workloads.py # Multi-user workflow definitions
├── oauth_metrics.py # Enhanced metrics collection
├── oauth_benchmark.py # Main CLI entry point
└── README_OAUTH.md # This file
```
### Key Classes
**OAuthUserPool** (`oauth_pool.py`)
- Manages N OAuth-authenticated users
- Handles token acquisition and storage
- Creates and manages MCP sessions per user
- Tracks per-user operation statistics
**UserSessionWrapper** (`oauth_pool.py`)
- Wraps MCP ClientSession for a specific user
- Automatic operation tracking
- Convenient tool/resource access methods
**Workflow** (`oauth_workloads.py`)
- Base class for multi-user coordinated workflows
- Step-by-step execution with timing
- Comprehensive error handling and reporting
**OAuthBenchmarkMetrics** (`oauth_metrics.py`)
- Per-user operation counts and latencies
- Workflow completion rates and timings
- Baseline operation statistics
- Detailed reporting and JSON export
## Available Workflows
### 1. NoteShareWorkflow
**Scenario**: Alice creates a note and shares it with Bob, who then reads it.
**Steps**:
1. User A creates a note
2. User A shares note with User B (read-only permissions)
3. User B lists their shared notes (measures propagation delay)
4. User B reads the shared note
**Metrics**: Creation latency, share propagation time, read latency
### 2. CollaborativeEditWorkflow
**Scenario**: Multiple users concurrently edit the same note.
**Steps**:
1. Owner creates a note
2. All users read the note simultaneously
3. All users append content concurrently
4. Owner verifies final state
**Metrics**: Concurrent read latency, concurrent write conflicts, final state consistency
### 3. FileShareAndDownloadWorkflow
**Scenario**: Alice uploads a file, shares it with Bob, who then downloads it.
**Steps**:
1. User A creates a file via WebDAV
2. User A shares file with User B (read-only)
3. User B lists their shares
4. User B downloads the file
**Metrics**: Upload latency, share creation, download latency
### 4. MixedOAuthWorkload
**Distribution**:
- 50% Baseline operations (individual user CRUD)
- 30% Note sharing workflows
- 15% Collaborative editing workflows
- 5% File sharing workflows
## Usage
### Basic Usage
```bash
# 4 users, 60-second test with mixed workload
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
# 10 users, 5-minute test
uv run python -m tests.load.oauth_benchmark -u 10 -d 300
# Export results to JSON
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
```
### Advanced Options
```bash
# Sharing-focused workload
uv run python -m tests.load.oauth_benchmark --workload sharing -u 8 -d 180
# Collaborative editing workload
uv run python -m tests.load.oauth_benchmark --workload collaboration -u 6 -d 120
# Baseline operations only (no workflows)
uv run python -m tests.load.oauth_benchmark --workload baseline -u 10 -d 60
# Verbose logging for debugging
uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
```
### CLI Options
| Option | Short | Default | Description |
|--------|-------|---------|-------------|
| `--users` | `-u` | 2 | Number of concurrent users (dynamically created) |
| `--duration` | `-d` | 30.0 | Test duration in seconds |
| `--warmup` | `-w` | 5.0 | Warmup period before metrics collection (seconds) |
| `--url` | | `http://localhost:8001/mcp` | MCP OAuth server URL |
| `--output` | `-o` | None | JSON output file path |
| `--workload` | | `mixed` | Workload type: mixed, sharing, collaboration, baseline |
| `--user-prefix` | | `loadtest` | Prefix for dynamically created usernames |
| `--cleanup/--no-cleanup` | | `cleanup` | Delete created users after benchmark |
| `--browser` | | `chromium` | Playwright browser: firefox, chromium, webkit |
| `--headed` | | False | Run browser in headed mode (visible window) |
| `--verbose` | `-v` | False | Enable verbose logging |
## Test User Creation
The framework **dynamically creates test users** on-demand with OAuth authentication:
- **Naming**: Users are created with the pattern `{prefix}_user_{n}` (default: `loadtest_user_1`, `loadtest_user_2`, etc.)
- **Customization**: Use `--user-prefix` to change the prefix (e.g., `--user-prefix mytest``mytest_user_1`)
- **Scalability**: No limit on user count - create as many concurrent users as your system can handle
- **Credentials**: Each user gets a randomly generated secure password
- **OAuth Tokens**: All users authenticate via automated OAuth flow using Playwright
- **Cleanup**: Users are automatically deleted after the benchmark (disable with `--no-cleanup`)
**Example**: Running `--users 5` creates:
- `loadtest_user_1` (Display: Load Test User 1, Email: loadtest_user_1@benchmark.local)
- `loadtest_user_2` (Display: Load Test User 2, Email: loadtest_user_2@benchmark.local)
- `loadtest_user_3` (Display: Load Test User 3, Email: loadtest_user_3@benchmark.local)
- `loadtest_user_4` (Display: Load Test User 4, Email: loadtest_user_4@benchmark.local)
- `loadtest_user_5` (Display: Load Test User 5, Email: loadtest_user_5@benchmark.local)
## Metrics Output
### Console Report
```
================================================================================
OAUTH MULTI-USER BENCHMARK RESULTS
================================================================================
Duration: 120.45s
Total Users: 5
Total Workflows Executed: 312
Total Baseline Operations: 678
--------------------------------------------------------------------------------
WORKFLOW STATISTICS
--------------------------------------------------------------------------------
Workflow Total Success Rate P50 P95
--------------------------------------------------------------------------------
note_share 112 109 97.3% 0.2341s 0.4782s
collaborative_edit 65 61 93.8% 0.5123s 0.9234s
file_share 29 29 100.0% 0.3456s 0.6123s
--------------------------------------------------------------------------------
PER-USER STATISTICS
--------------------------------------------------------------------------------
User Total Ops Success Errors Rate P50
--------------------------------------------------------------------------------
loadtest_user_1 289 283 6 97.9% 0.2456s
loadtest_user_2 245 241 4 98.4% 0.2123s
loadtest_user_3 231 226 5 97.8% 0.2345s
loadtest_user_4 198 195 3 98.5% 0.2234s
loadtest_user_5 187 184 3 98.4% 0.2189s
--------------------------------------------------------------------------------
BASELINE OPERATIONS
--------------------------------------------------------------------------------
Total Operations: 678
Success Rate: 98.2%
Latency: min=0.0234s, p50=0.1234s, p95=0.3456s, max=0.8123s
================================================================================
```
### JSON Export
```json
{
"summary": {
"duration": 120.45,
"total_workflows": 312,
"total_baseline_ops": 678,
"total_users": 5
},
"workflows": {
"note_share": {
"total_executions": 112,
"successful_executions": 109,
"failed_executions": 3,
"success_rate": 97.3,
"latency": {
"min": 0.1234,
"max": 0.8765,
"mean": 0.2891,
"median": 0.2341,
"p90": 0.4123,
"p95": 0.4782,
"p99": 0.7234
},
"step_latencies": {
"create_note": {...},
"share_note": {...},
"list_shared_with_me": {...},
"read_shared_note": {...}
}
}
},
"users": {
"loadtest_user_1": {
"total_operations": 289,
"successful_operations": 283,
"failed_operations": 6,
"success_rate": 97.9,
"latency": {...},
"operations_breakdown": {...},
"errors_breakdown": {...}
},
"loadtest_user_2": {...},
"loadtest_user_3": {...},
"loadtest_user_4": {...},
"loadtest_user_5": {...}
},
"baseline": {...}
}
```
## Implementation Status
### ✅ Completed Components
**Framework:**
- OAuth user pool management with dynamic user creation
- User session wrappers with automatic tracking
- Workflow base classes and framework
- 3 example workflows (note share, collaborative edit, file share)
- Enhanced metrics with per-user and workflow tracking
- CLI interface with multiple workload options
- Comprehensive reporting (console + JSON)
**OAuth Integration:**
- ✅ Playwright browser automation for OAuth login
- ✅ OAuth callback server for auth code capture
- ✅ Token exchange with OIDC provider
- ✅ OAuth token injection into MCP sessions via Authorization headers
- ✅ Cancel scope error handling for reliable cleanup
- ✅ Dynamic user creation and deletion via Nextcloud Users API
**Implementation Details:**
The benchmark now successfully:
1. Creates Nextcloud users dynamically with unique passwords
2. Acquires OAuth tokens via automated Playwright browser flows
3. Creates MCP client sessions with proper `Authorization: Bearer {token}` headers
4. Executes coordinated multi-user workflows
5. Tracks per-user and per-workflow metrics
6. Provides standalone cleanup utility for test users
**Key Fix (oauth_pool.py:163-164)**:
```python
# Pass OAuth token as Authorization header
headers = {"Authorization": f"Bearer {profile.token}"}
streamable_context = streamablehttp_client(mcp_url, headers=headers)
```
## Creating Custom Workflows
### Example: Permission Escalation Workflow
```python
class PermissionEscalationWorkflow(Workflow):
"""Test sharing permission changes."""
def __init__(self):
super().__init__("permission_escalation")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires 2+ users")
owner, collaborator = users[0], users[1]
# Step 1: Owner creates note
create_result = await self._execute_step(
"create_note",
owner,
lambda: owner.call_tool("nc_notes_create_note", {...})
)
# Step 2: Share read-only
await self._execute_step(
"share_readonly",
owner,
lambda: owner.call_tool("nc_share_create", {
"permissions": 1 # Read-only
})
)
# Step 3: Upgrade to edit permissions
await self._execute_step(
"upgrade_permissions",
owner,
lambda: owner.call_tool("nc_share_update", {
"permissions": 15 # Read+update+create+delete
})
)
# Step 4: Collaborator edits
await self._execute_step(
"collaborator_edit",
collaborator,
lambda: collaborator.call_tool("nc_notes_update_note", {...})
)
return self._finish(success=True)
```
### Registering Custom Workflows
```python
# In oauth_workloads.py
class MixedOAuthWorkload:
def __init__(self, users: list[UserSessionWrapper]):
self.users = users
self.workflows = {
"note_share": NoteShareWorkflow(),
"collaborative_edit": CollaborativeEditWorkflow(),
"file_share": FileShareAndDownloadWorkflow(),
"permission_escalation": PermissionEscalationWorkflow(), # Add your workflow
}
```
## Performance Expectations
### Baseline Performance (basic auth, from existing benchmarks)
- **Throughput**: 50-200 RPS for mixed workload
- **Latency**: p50 <100ms, p95 <500ms, p99 <1000ms
### OAuth Multi-User Expectations
- **Lower throughput**: ~30-60% of baseline due to:
- OAuth token validation overhead
- Cross-user synchronization delays
- Workflow coordination overhead
- **Higher p99 latency**: Due to workflow step dependencies
- **Focus**: End-to-end workflow completion time more important than raw RPS
### Common Bottlenecks
1. **OAuth token validation**: Per-request overhead
2. **Share propagation**: Time for shares to become visible to recipients
3. **Concurrent edit conflicts**: ETags and conflict resolution
4. **Permission checks**: Cross-user access validation
## Best Practices
1. **Start Small**: Begin with 2-3 users to validate workflows
2. **Monitor Errors**: Watch for permission errors and conflicts
3. **Adjust Delays**: Tune sleep delays between operations based on server response
4. **Profile Workflows**: Use step latencies to identify bottlenecks
5. **Export Results**: Always export to JSON for historical comparison
## Performance Optimizations
### Concurrent User Creation
The benchmark creates and authenticates users **concurrently** for maximum performance:
**Step 5: User Creation & OAuth Authentication**
- All N users are created in parallel using `asyncio.gather()`
- Each user runs through the full OAuth flow simultaneously
- Multiple Playwright browser contexts operate independently
**Step 6: MCP Session Creation**
- All user sessions are created concurrently
- OAuth tokens passed as Authorization headers to each session
**Performance Impact:**
- **Sequential** (old): ~10-12s per user → 40-48s for 4 users
- **Concurrent** (new): ~12-15s total for 4 users (3-4x speedup!)
Example output showing concurrent execution:
```
Step 5/6: Creating 4 users and acquiring OAuth tokens...
(Running concurrently for faster setup)
[1/4] Creating user 'loadtest_user_1'...
[2/4] Creating user 'loadtest_user_2'...
[3/4] Creating user 'loadtest_user_3'...
[4/4] Creating user 'loadtest_user_4'...
✓ User 'loadtest_user_4' authenticated
✓ User 'loadtest_user_2' authenticated
✓ User 'loadtest_user_1' authenticated
✓ User 'loadtest_user_3' authenticated
✓ Successfully created and authenticated 4 users
```
**Implementation** (oauth_benchmark.py:402-437):
```python
# Create tasks for all users
tasks = [
create_user_task(i, browser, callback_server.auth_states)
for i in range(num_users)
]
# Run all concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
```
## Cleanup
**Important**: Due to asyncio scoping issues with the MCP client library, automatic cleanup in the benchmark's finally block may not execute reliably. Always use the cleanup utility after running benchmarks.
### Cleanup Utility (Recommended)
Use the cleanup utility to remove test users:
```bash
# Dry run - see what would be deleted
uv run python -m tests.load.cleanup_loadtest_users --dry-run
# Delete all loadtest users
uv run python -m tests.load.cleanup_loadtest_users
# Delete users with custom prefix
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
```
### Disable Automatic Cleanup
To keep test users after the benchmark for inspection:
```bash
uv run python -m tests.load.oauth_benchmark --users 2 --no-cleanup
```
## Troubleshooting
### Leftover Test Users
**Symptom**: Test users remain in Nextcloud after benchmark crashes
**Solution**: Run the cleanup utility:
```bash
uv run python -m tests.load.cleanup_loadtest_users
```
### "User X not in pool" Error
- Ensure user count doesn't exceed configured limits
- Check that user creation succeeded in previous steps
### CancelledError During Benchmark
**Symptom**: Error message like `'CancelledError' object has no attribute 'username'` appears in logs
**Cause**: Async task cancellation during benchmark shutdown or errors can cause race conditions in error handling
**Solution**: This has been mitigated with defensive error handling. The worker now:
- Catches `asyncio.CancelledError` specifically before general exceptions
- Logs cancellation gracefully without attempting to access potentially invalid state
- Re-raises the exception to allow proper cleanup chain
If you still see this error, it's likely harmless and occurs during shutdown. The benchmark results should still be valid.
### High Error Rates
- Increase delay between operations (`await asyncio.sleep()` in worker)
- Check OAuth token validity
- Verify MCP OAuth server is running and accessible (port 8001)
- Rebuild mcp-oauth container after code changes: `docker-compose up --build -d mcp-oauth`
### Workflows Failing
- Check step-by-step latencies to identify failing steps
- Verify users have correct permissions
- Review server logs for errors
### MCP Session Creation Fails (401 Unauthorized)
**Solution**: This issue has been fixed! OAuth tokens are now properly passed as Authorization headers when creating MCP sessions.
If you still see 401 errors:
- Rebuild the mcp-oauth container: `docker-compose up --build -d mcp-oauth`
- Verify OAuth tokens are being acquired successfully in verbose mode
- Check that the token hasn't expired (use shorter test durations during troubleshooting)
## Future Enhancements
- [x] Dynamic user creation (beyond 4 default users) - **COMPLETED**
- [x] OAuth token injection for MCP sessions - **COMPLETED**
- [x] Cancel scope error handling - **COMPLETED**
- [x] Concurrent user creation and authentication - **COMPLETED** (3-4x speedup!)
- [ ] Workflow templates for common patterns
- [ ] Real-time dashboard for live monitoring
- [ ] Historical comparison and regression detection
- [ ] Load ramping (gradual user increase)
- [ ] Geographic distribution simulation (latency injection)
- [ ] Improve cleanup reliability in finally block
+1
View File
@@ -0,0 +1 @@
"""Load testing utilities for Nextcloud MCP Server."""
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""
Load testing benchmark for Nextcloud MCP Server.
Usage:
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
uv run python -m tests.load.benchmark -c 50 -d 300 --output results.json
"""
import json
import logging
import signal
import statistics
import sys
import time
from collections import Counter
from contextlib import asynccontextmanager
from typing import Any
import anyio
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from tests.load.workloads import MixedWorkload, OperationResult, WorkloadOperations
logging.basicConfig(
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
class BenchmarkMetrics:
"""Collect and analyze benchmark metrics."""
def __init__(self):
self.results: list[OperationResult] = []
self.start_time: float | None = None
self.end_time: float | None = None
self._operation_counts: Counter = Counter()
self._operation_errors: Counter = Counter()
def add_result(self, result: OperationResult):
"""Add a single operation result."""
self.results.append(result)
self._operation_counts[result.operation] += 1
if not result.success:
self._operation_errors[result.operation] += 1
def start(self):
"""Mark the start of the benchmark."""
self.start_time = time.time()
def stop(self):
"""Mark the end of the benchmark."""
self.end_time = time.time()
@property
def duration(self) -> float:
"""Total benchmark duration in seconds."""
if self.start_time is None or self.end_time is None:
return 0.0
return self.end_time - self.start_time
@property
def total_requests(self) -> int:
"""Total number of requests made."""
return len(self.results)
@property
def successful_requests(self) -> int:
"""Number of successful requests."""
return sum(1 for r in self.results if r.success)
@property
def failed_requests(self) -> int:
"""Number of failed requests."""
return sum(1 for r in self.results if not r.success)
@property
def error_rate(self) -> float:
"""Error rate as a percentage."""
if self.total_requests == 0:
return 0.0
return (self.failed_requests / self.total_requests) * 100
@property
def requests_per_second(self) -> float:
"""Average requests per second."""
if self.duration == 0:
return 0.0
return self.total_requests / self.duration
def latency_stats(self) -> dict[str, float]:
"""Calculate latency statistics."""
if not self.results:
return {
"min": 0.0,
"max": 0.0,
"mean": 0.0,
"median": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
}
durations = [r.duration for r in self.results]
sorted_durations = sorted(durations)
def percentile(data: list[float], p: float) -> float:
k = (len(data) - 1) * p
f = int(k)
c = f + 1
if c >= len(data):
return data[-1]
return data[f] + (k - f) * (data[c] - data[f])
return {
"min": min(durations),
"max": max(durations),
"mean": statistics.mean(durations),
"median": statistics.median(durations),
"p90": percentile(sorted_durations, 0.90),
"p95": percentile(sorted_durations, 0.95),
"p99": percentile(sorted_durations, 0.99),
}
def operation_breakdown(self) -> dict[str, dict[str, Any]]:
"""Get per-operation statistics."""
breakdown = {}
for op_name in self._operation_counts:
op_results = [r for r in self.results if r.operation == op_name]
op_durations = [r.duration for r in op_results if r.success]
if op_durations:
sorted_durations = sorted(op_durations)
p50 = statistics.median(sorted_durations)
p95_idx = int(len(sorted_durations) * 0.95)
p95 = sorted_durations[min(p95_idx, len(sorted_durations) - 1)]
else:
p50 = p95 = 0.0
breakdown[op_name] = {
"count": self._operation_counts[op_name],
"errors": self._operation_errors[op_name],
"success_rate": (
(self._operation_counts[op_name] - self._operation_errors[op_name])
/ self._operation_counts[op_name]
* 100
),
"p50_latency": p50,
"p95_latency": p95,
}
return breakdown
def to_dict(self) -> dict[str, Any]:
"""Convert metrics to dictionary for JSON export."""
return {
"summary": {
"duration": self.duration,
"total_requests": self.total_requests,
"successful_requests": self.successful_requests,
"failed_requests": self.failed_requests,
"error_rate": self.error_rate,
"requests_per_second": self.requests_per_second,
},
"latency": self.latency_stats(),
"operations": self.operation_breakdown(),
}
def print_report(self):
"""Print human-readable benchmark report."""
print("\n" + "=" * 80)
print("BENCHMARK RESULTS")
print("=" * 80)
print(f"\nDuration: {self.duration:.2f}s")
print(f"Total Requests: {self.total_requests}")
print(f"Successful: {self.successful_requests}")
print(f"Failed: {self.failed_requests}")
print(f"Error Rate: {self.error_rate:.2f}%")
print(f"Requests/Second: {self.requests_per_second:.2f}")
print("\n" + "-" * 80)
print("LATENCY (seconds)")
print("-" * 80)
latency = self.latency_stats()
print(f"Min: {latency['min']:.4f}s")
print(f"Mean: {latency['mean']:.4f}s")
print(f"Median: {latency['median']:.4f}s")
print(f"P90: {latency['p90']:.4f}s")
print(f"P95: {latency['p95']:.4f}s")
print(f"P99: {latency['p99']:.4f}s")
print(f"Max: {latency['max']:.4f}s")
print("\n" + "-" * 80)
print("OPERATION BREAKDOWN")
print("-" * 80)
print(
f"{'Operation':<25} {'Count':>8} {'Errors':>8} {'Success':>9} {'P50':>10} {'P95':>10}"
)
print("-" * 80)
breakdown = self.operation_breakdown()
for op_name, stats in sorted(breakdown.items()):
print(
f"{op_name:<25} {stats['count']:>8} {stats['errors']:>8} "
f"{stats['success_rate']:>8.1f}% {stats['p50_latency']:>9.4f}s {stats['p95_latency']:>9.4f}s"
)
print("=" * 80 + "\n")
@asynccontextmanager
async def create_mcp_session(url: str):
"""Create an MCP client session with proper cleanup."""
logger.info(f"Creating MCP client session for {url}")
streamable_context = streamablehttp_client(url)
session_context = None
try:
read_stream, write_stream, _ = await streamable_context.__aenter__()
session_context = ClientSession(read_stream, write_stream)
session = await session_context.__aenter__()
await session.initialize()
logger.info("MCP client session initialized")
yield session
finally:
if session_context is not None:
try:
await session_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing session: {e}")
try:
await streamable_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing streamable context: {e}")
async def wait_for_mcp_server(url: str, max_attempts: int = 10) -> bool:
"""Wait for MCP server to be ready."""
logger.info(f"Waiting for MCP server at {url}...")
for attempt in range(1, max_attempts + 1):
try:
async with create_mcp_session(url) as session:
# Try to get capabilities
await session.read_resource("nc://capabilities")
logger.info("MCP server is ready")
return True
except Exception as e:
if attempt < max_attempts:
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
await anyio.sleep(2)
else:
logger.error(f"MCP server not ready after {max_attempts} attempts")
return False
return False
async def benchmark_worker(
worker_id: int,
url: str,
duration: float,
metrics: BenchmarkMetrics,
stop_event: anyio.Event,
):
"""Single worker that runs operations for the specified duration."""
logger.info(f"Worker {worker_id} starting...")
try:
async with create_mcp_session(url) as session:
ops = WorkloadOperations(session)
workload = MixedWorkload(ops)
# Warmup
await workload.warmup(count=5)
# Run operations until duration expires or stop event is set
start_time = time.time()
operation_count = 0
while not stop_event.is_set():
if time.time() - start_time >= duration:
break
result = await workload.run_operation()
metrics.add_result(result)
operation_count += 1
# Small delay to prevent overwhelming the server
await anyio.sleep(0.01)
# Cleanup
await ops.cleanup()
logger.info(f"Worker {worker_id} completed {operation_count} operations")
except Exception as e:
logger.error(f"Worker {worker_id} error: {e}", exc_info=True)
async def run_benchmark(
url: str,
concurrency: int,
duration: float,
warmup: float = 5.0,
) -> BenchmarkMetrics:
"""Run the benchmark with specified parameters."""
metrics = BenchmarkMetrics()
stop_event = anyio.Event()
# Setup signal handlers for graceful shutdown
def signal_handler(sig, frame):
logger.warning("Received interrupt signal, stopping benchmark...")
stop_event.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print(
f"\nStarting benchmark with {concurrency} concurrent workers for {duration}s..."
)
print(f"Target: {url}")
print(f"Warmup period: {warmup}s\n")
# Warmup period
if warmup > 0:
print("Warming up...")
await anyio.sleep(warmup)
# Start metrics collection
metrics.start()
# Create and run workers using anyio task groups
async with anyio.create_task_group() as tg:
# Start all workers
for i in range(concurrency):
tg.start_soon(benchmark_worker, i, url, duration, metrics, stop_event)
# Show progress
tg.start_soon(show_progress, duration, metrics, stop_event)
# Stop metrics (tasks already completed when task group exits)
metrics.stop()
return metrics
async def show_progress(
duration: float,
metrics: BenchmarkMetrics,
stop_event: anyio.Event,
):
"""Show real-time progress during benchmark."""
start_time = time.time()
while not stop_event.is_set():
elapsed = time.time() - start_time
if elapsed >= duration:
break
# Calculate progress
progress = min(elapsed / duration * 100, 100)
rps = metrics.total_requests / max(elapsed, 0.1)
# Print progress bar
bar_length = 40
filled = int(bar_length * progress / 100)
bar = "" * filled + "" * (bar_length - filled)
print(
f"\r[{bar}] {progress:5.1f}% | "
f"Requests: {metrics.total_requests:6d} | "
f"RPS: {rps:6.1f} | "
f"Errors: {metrics.failed_requests:4d}",
end="",
flush=True,
)
await anyio.sleep(0.5)
print() # New line after progress
@click.command()
@click.option(
"--concurrency",
"-c",
type=int,
default=10,
show_default=True,
help="Number of concurrent workers",
)
@click.option(
"--duration",
"-d",
type=float,
default=30.0,
show_default=True,
help="Test duration in seconds",
)
@click.option(
"--warmup",
"-w",
type=float,
default=5.0,
show_default=True,
help="Warmup duration before collecting metrics (seconds)",
)
@click.option(
"--url",
"-u",
default="http://localhost:8000/mcp",
show_default=True,
help="MCP server URL",
)
@click.option(
"--output",
"-o",
type=click.Path(),
help="Output file for JSON results (optional)",
)
@click.option(
"--wait-for-server/--no-wait",
default=True,
show_default=True,
help="Wait for MCP server to be ready before starting",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Enable verbose logging",
)
def main(
concurrency: int,
duration: float,
warmup: float,
url: str,
output: str | None,
wait_for_server: bool,
verbose: bool,
):
"""
Load testing benchmark for Nextcloud MCP Server.
Runs a mixed workload of realistic MCP operations against the server
and reports detailed performance metrics.
Examples:
# Quick 30-second test with 10 workers
uv run python -m tests.load.benchmark --concurrency 10 --duration 30
# Extended test with 50 workers for 5 minutes
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON
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://localhost:8001/mcp
"""
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("tests.load").setLevel(logging.DEBUG)
async def run():
# Wait for server if requested
if wait_for_server:
if not await wait_for_mcp_server(url):
print("ERROR: MCP server is not ready", file=sys.stderr)
sys.exit(1)
# Run benchmark
metrics = await run_benchmark(url, concurrency, duration, warmup)
# Print report
metrics.print_report()
# Export to JSON if requested
if output:
with open(output, "w") as f:
json.dump(metrics.to_dict(), f, indent=2)
print(f"Results exported to: {output}")
try:
anyio.run(run)
except KeyboardInterrupt:
print("\nBenchmark interrupted by user")
sys.exit(130)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
if verbose:
raise
sys.exit(1)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Cleanup utility for loadtest users.
Searches for and deletes all users with 'loadtest' prefix in their username.
Useful for cleaning up after failed benchmark runs.
Usage:
uv run python -m tests.load.cleanup_loadtest_users
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
uv run python -m tests.load.cleanup_loadtest_users --dry-run
"""
import sys
import anyio
import click
from nextcloud_mcp_server.client import NextcloudClient
async def cleanup_users(prefix: str = "loadtest", dry_run: bool = False):
"""
Search for and delete users with the specified prefix.
Args:
prefix: Username prefix to search for
dry_run: If True, only list users without deleting them
"""
print(f"Searching for users with prefix '{prefix}'...")
try:
client = NextcloudClient.from_env()
users = await client.users.search_users(search=prefix)
if not users:
print(f"✓ No users found with prefix '{prefix}'")
return
print(f"Found {len(users)} user(s): {', '.join(users)}\n")
if dry_run:
print("DRY RUN - No users will be deleted")
for user in users:
print(f" Would delete: {user}")
print("\nTo actually delete these users, run without --dry-run flag")
return
# Delete users
deleted = []
failed = []
for user in users:
try:
print(f" Deleting {user}...")
await client.users.delete_user(userid=user)
deleted.append(user)
print(f" ✓ Deleted {user}")
except Exception as e:
failed.append((user, str(e)))
print(f" ✗ Failed to delete {user}: {e}")
# Summary
print(f"\n{'=' * 60}")
print("Cleanup Summary")
print(f"{'=' * 60}")
print(f"Successfully deleted: {len(deleted)}")
print(f"Failed to delete: {len(failed)}")
if failed:
print("\nFailed deletions:")
for user, error in failed:
print(f" - {user}: {error}")
sys.exit(1)
else:
print("\n✓ All users cleaned up successfully")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
@click.command()
@click.option(
"--prefix",
default="loadtest",
show_default=True,
help="Username prefix to search for",
)
@click.option(
"--dry-run",
is_flag=True,
help="List users without deleting them",
)
def main(prefix: str, dry_run: bool):
"""
Cleanup loadtest users from Nextcloud.
Searches for all users with the specified prefix and deletes them.
Useful for cleaning up after failed benchmark runs.
Examples:
# Dry run to see what would be deleted
uv run python -m tests.load.cleanup_loadtest_users --dry-run
# Delete all loadtest users
uv run python -m tests.load.cleanup_loadtest_users
# Delete users with custom prefix
uv run python -m tests.load.cleanup_loadtest_users --prefix mytest
"""
anyio.run(cleanup_users, prefix, dry_run)
if __name__ == "__main__":
main()
+768
View File
@@ -0,0 +1,768 @@
#!/usr/bin/env python3
"""
OAuth Multi-User Load Testing for Nextcloud MCP Server.
Simulates realistic multi-user scenarios with coordinated workflows
like note sharing, collaborative editing, and file operations.
Usage:
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
"""
import json
import logging
import os
import secrets
import signal
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any
from urllib.parse import parse_qs, urlparse
import anyio
import click
import httpx
from playwright.async_api import async_playwright
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
from nextcloud_mcp_server.client import NextcloudClient
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
from tests.load.oauth_pool import (
OAuthUserPool,
UserSessionWrapper,
generate_secure_password,
)
from tests.load.oauth_workloads import MixedOAuthWorkload, WorkflowResult
logging.basicConfig(
level=logging.WARNING, format="%(levelname)s [%(asctime)s] %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
class OAuthCallbackServer:
"""
Temporary HTTP server to capture OAuth authorization codes.
Runs in a background thread, captures auth codes via state parameter
correlation, and stores them in a shared dictionary.
"""
def __init__(self, host: str = "localhost", port: int = 8081):
self.host = host
self.port = port
self.auth_states: dict[str, str] = {}
self.server: HTTPServer | None = None
self.thread: threading.Thread | None = None
def start(self):
"""Start the callback server in a background thread."""
class CallbackHandler(BaseHTTPRequestHandler):
auth_states = self.auth_states
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/callback":
params = parse_qs(parsed.query)
code = params.get("code", [None])[0]
state = params.get("state", [None])[0]
if code and state:
self.auth_states[state] = code
logger.info(f"Captured auth code for state {state[:16]}...")
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h1>Authorization successful!</h1>"
b"<p>You can close this window.</p></body></html>"
)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
# Suppress default logging
pass
self.server = HTTPServer((self.host, self.port), CallbackHandler)
def run():
logger.info(f"OAuth callback server listening on {self.host}:{self.port}")
self.server.serve_forever()
self.thread = threading.Thread(target=run, daemon=True)
self.thread.start()
logger.info("OAuth callback server started")
def stop(self):
"""Stop the callback server."""
if self.server:
self.server.shutdown()
logger.info("OAuth callback server stopped")
def get_auth_code(self, state: str) -> str | None:
"""Get auth code for a given state parameter."""
return self.auth_states.get(state)
async def discover_oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
"""
Discover OIDC endpoints from Nextcloud's .well-known configuration.
Args:
nextcloud_host: Nextcloud host URL (e.g., http://localhost:8080)
Returns:
Dict with authorization_endpoint, token_endpoint, and registration_endpoint
"""
logger.info("Discovering OIDC endpoints...")
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
response = await client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
response.raise_for_status()
config = response.json()
endpoints = {
"authorization_endpoint": config["authorization_endpoint"],
"token_endpoint": config["token_endpoint"],
"registration_endpoint": config["registration_endpoint"],
}
logger.info(f"Discovered endpoints: {endpoints}")
return endpoints
async def setup_oauth_client(
nextcloud_host: str, callback_url: str, registration_endpoint: str
) -> dict[str, str]:
"""
Setup OAuth client using load_or_register_client.
Args:
nextcloud_host: Nextcloud host URL
callback_url: OAuth callback URL
registration_endpoint: OAuth registration endpoint URL
Returns:
Dict with client_id and client_secret
"""
logger.info("Setting up OAuth client...")
# Use the client registration utility
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_benchmark_client.json",
client_name="OAuth Benchmark Test Client",
redirect_uris=[callback_url],
)
logger.info(f"OAuth client setup complete (client_id: {client_info.client_id})")
return {
"client_id": client_info.client_id,
"client_secret": client_info.client_secret,
}
async def create_and_authenticate_user(
user_pool: OAuthUserPool,
browser: Any,
auth_states: dict[str, str],
username: str,
password: str,
display_name: str | None = None,
) -> str:
"""
Create Nextcloud user and acquire OAuth token via Playwright.
Args:
user_pool: OAuthUserPool instance
browser: Playwright browser instance
auth_states: Shared auth_states dict for callback server
username: Username to create
password: Password for the user
display_name: Optional display name
Returns:
OAuth access token for the user
"""
logger.info(f"Creating and authenticating user: {username}")
# Create Nextcloud user
await user_pool.create_nextcloud_user(
username=username,
password=password,
display_name=display_name or username,
)
# Generate unique state for this OAuth flow
state = secrets.token_urlsafe(32)
# Acquire OAuth token via Playwright
token = await user_pool.acquire_token_playwright(
browser=browser,
username=username,
password=password,
state=state,
auth_states=auth_states,
)
logger.info(f"Successfully authenticated user: {username}")
return token
async def oauth_benchmark_worker(
user_wrapper: UserSessionWrapper,
workload: MixedOAuthWorkload,
duration: float,
metrics: OAuthBenchmarkMetrics,
stop_event: anyio.Event,
):
"""
Single worker executing operations for one user.
Args:
user_wrapper: UserSessionWrapper for this worker
workload: MixedOAuthWorkload instance
duration: Test duration in seconds
metrics: Metrics collector
stop_event: Event to signal stop
"""
logger.info(f"Worker for {user_wrapper.username} starting...")
start_time = time.time()
operation_count = 0
try:
while not stop_event.is_set():
if time.time() - start_time >= duration:
break
# Run an operation (might be baseline or workflow)
result = await workload.run_operation()
# Record metrics
if isinstance(result, WorkflowResult):
metrics.add_workflow_result(result)
else:
# Baseline operation
metrics.add_baseline_operation(result)
operation_count += 1
# Small delay to prevent overwhelming the server
await anyio.sleep(0.05)
logger.info(
f"Worker for {user_wrapper.username} completed {operation_count} operations"
)
except anyio.get_cancelled_exc_class():
# Handle task cancellation gracefully (e.g., during benchmark shutdown)
logger.info(
f"Worker for {user_wrapper.username} was cancelled "
f"(completed {operation_count} operations)"
)
raise # Re-raise to allow proper cleanup
except Exception as e:
logger.error(f"Worker {user_wrapper.username} error: {e}", exc_info=True)
async def show_progress(
duration: float,
metrics: OAuthBenchmarkMetrics,
stop_event: anyio.Event,
):
"""Show real-time progress during benchmark."""
start_time = time.time()
while not stop_event.is_set():
elapsed = time.time() - start_time
if elapsed >= duration:
break
# Calculate progress
progress = min(elapsed / duration * 100, 100)
total_ops = len(metrics.baseline_operations) + len(metrics.workflows)
workflows = len(metrics.workflows)
# Print progress bar
bar_length = 40
filled = int(bar_length * progress / 100)
bar = "" * filled + "" * (bar_length - filled)
print(
f"\r[{bar}] {progress:5.1f}% | "
f"Total Ops: {total_ops:6d} | "
f"Workflows: {workflows:4d}",
end="",
flush=True,
)
await anyio.sleep(0.5)
print() # New line after progress
async def run_oauth_benchmark(
num_users: int,
duration: float,
mcp_url: str,
warmup: float = 5.0,
user_prefix: str = "loadtest",
cleanup: bool = True,
browser_type: str = "firefox",
headed: bool = False,
) -> OAuthBenchmarkMetrics:
"""
Run the OAuth multi-user benchmark with dynamic user creation.
Args:
num_users: Number of concurrent users to create
duration: Test duration in seconds
mcp_url: MCP server URL
warmup: Warmup period in seconds
user_prefix: Prefix for generated usernames
cleanup: Whether to delete users after benchmark
browser_type: Playwright browser type (firefox, chromium, webkit)
headed: Whether to run browser in headed mode
Returns:
OAuthBenchmarkMetrics with results
"""
metrics = OAuthBenchmarkMetrics()
stop_event = anyio.Event()
created_users: list[str] = []
callback_server: OAuthCallbackServer | None = None
user_pool: OAuthUserPool | None = None
admin_client: NextcloudClient | None = None
# Setup signal handlers for graceful shutdown
def signal_handler(sig, frame):
logger.warning("Received interrupt signal, stopping benchmark...")
stop_event.set()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
print(f"\n{'=' * 80}")
print("OAUTH MULTI-USER BENCHMARK")
print(f"{'=' * 80}")
print(f"Users: {num_users} | Duration: {duration}s | Warmup: {warmup}s")
print(f"Target: {mcp_url}")
print(f"User Prefix: {user_prefix} | Cleanup: {cleanup}")
print(f"Browser: {browser_type} | Headed: {headed}")
print(f"{'=' * 80}\n")
try:
# Get environment variables
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
callback_url = "http://localhost:8081/callback"
# Step 1: Start OAuth callback server
print("Step 1/6: Starting OAuth callback server...")
callback_server = OAuthCallbackServer(host="localhost", port=8081)
callback_server.start()
print("✓ Callback server listening on http://localhost:8081\n")
# Step 2: Discover OIDC endpoints
print("Step 2/6: Discovering OIDC endpoints...")
endpoints = await discover_oidc_endpoints(nextcloud_host)
print(f"✓ Authorization endpoint: {endpoints['authorization_endpoint']}")
print(f"✓ Token endpoint: {endpoints['token_endpoint']}")
print(f"✓ Registration endpoint: {endpoints['registration_endpoint']}\n")
# Step 3: Setup OAuth client
print("Step 3/6: Setting up OAuth client...")
oauth_credentials = await setup_oauth_client(
nextcloud_host, callback_url, endpoints["registration_endpoint"]
)
print(f"✓ OAuth client registered (ID: {oauth_credentials['client_id']})\n")
# Step 4: Create admin client and user pool
print("Step 4/6: Initializing admin client and user pool...")
admin_client = NextcloudClient.from_env()
user_pool = OAuthUserPool(
admin_client=admin_client,
client_id=oauth_credentials["client_id"],
client_secret=oauth_credentials["client_secret"],
callback_url=callback_url,
token_endpoint=endpoints["token_endpoint"],
authorization_endpoint=endpoints["authorization_endpoint"],
)
async with user_pool:
print("✓ User pool initialized\n")
# Step 5: Create users and acquire OAuth tokens (concurrently)
print(f"Step 5/6: Creating {num_users} users and acquiring OAuth tokens...")
print("(Running concurrently for faster setup)\n")
async def create_user_task(
i: int, browser, auth_states: dict
) -> tuple[str, str, str] | None:
"""Create and authenticate a single user. Returns (username, password, token) or None on failure."""
username = f"{user_prefix}_user_{i + 1}"
password = generate_secure_password(16)
print(f" [{i + 1}/{num_users}] Creating user '{username}'...")
try:
token = await create_and_authenticate_user(
user_pool=user_pool,
browser=browser,
auth_states=auth_states,
username=username,
password=password,
display_name=f"Load Test User {i + 1}",
)
print(f" ✓ User '{username}' authenticated\n")
return (username, password, token)
except Exception as e:
logger.error(f"Failed to create/authenticate user {username}: {e}")
return None
async with async_playwright() as p:
# Launch browser
browser_launcher = getattr(p, browser_type)
browser = await browser_launcher.launch(headless=not headed)
try:
# Create all users concurrently using anyio task groups
results = []
async def run_and_collect(i: int):
"""Wrapper to collect results from tasks."""
try:
result = await create_user_task(
i, browser, callback_server.auth_states
)
results.append(result)
except Exception as e:
logger.error(f"User creation task failed: {e}")
results.append(e)
async with anyio.create_task_group() as tg:
for i in range(num_users):
tg.start_soon(run_and_collect, i)
# Process results
for result in results:
if isinstance(result, Exception):
logger.error(f"User creation task failed: {result}")
continue
if result is None:
continue
username, password, token = result
await user_pool.add_user(
username=username, password=password, token=token
)
created_users.append(username)
finally:
await browser.close()
if not created_users:
raise RuntimeError("Failed to create any users")
print(
f"✓ Successfully created and authenticated {len(created_users)} users\n"
)
# Step 6: Create MCP sessions for each user (concurrently)
print("Step 6/6: Creating MCP sessions for users...")
user_wrappers = []
async with user_pool:
async def create_session_task(username: str) -> UserSessionWrapper | None:
"""Create MCP session for a user. Returns wrapper or None on failure."""
try:
session = await user_pool.create_user_session(username, mcp_url)
wrapper = UserSessionWrapper(username, session, user_pool)
print(f" ✓ Session created for '{username}'")
return wrapper
except Exception as e:
logger.error(f"Failed to create session for {username}: {e}")
return None
# Create all sessions concurrently using anyio task groups
session_results = []
async def run_and_collect_session(username: str):
"""Wrapper to collect session results from tasks."""
try:
result = await create_session_task(username)
session_results.append(result)
except Exception as e:
logger.error(f"Session creation task failed: {e}")
session_results.append(e)
async with anyio.create_task_group() as tg:
for username in created_users:
tg.start_soon(run_and_collect_session, username)
# Process results
for result in session_results:
if isinstance(result, Exception):
logger.error(f"Session creation task failed: {result}")
continue
if result is not None:
user_wrappers.append(result)
if not user_wrappers:
raise RuntimeError("Failed to create any user sessions")
print(f"✓ Created {len(user_wrappers)} MCP sessions\n")
# Warmup period
if warmup > 0:
print(f"Warmup period: {warmup}s...")
await anyio.sleep(warmup)
print()
# Start benchmark
print(f"{'=' * 80}")
print("STARTING BENCHMARK")
print(f"{'=' * 80}\n")
metrics.start()
# Create workload and workers using anyio task groups
workload = MixedOAuthWorkload(user_wrappers)
# Run workers with progress display
async with anyio.create_task_group() as tg:
# Start all workers
for wrapper in user_wrappers:
tg.start_soon(
oauth_benchmark_worker,
wrapper,
workload,
duration,
metrics,
stop_event,
)
# Show progress
tg.start_soon(show_progress, duration, metrics, stop_event)
# Tasks already completed when task group exits
metrics.stop()
print(f"\n{'=' * 80}")
print("BENCHMARK COMPLETE")
print(f"{'=' * 80}\n")
# Cleanup user sessions
print("Closing user sessions...")
await user_pool.close_all_sessions()
print("✓ All sessions closed\n")
except Exception as e:
logger.error(f"Benchmark error: {e}", exc_info=True)
# Don't re-raise here - we want cleanup to run
finally:
# Cleanup callback server
if callback_server:
try:
callback_server.stop()
logger.info("OAuth callback server stopped")
except Exception as e:
logger.warning(f"Error stopping callback server: {e}")
# Cleanup test users
if cleanup and created_users:
print(f"\nCleaning up {len(created_users)} test users...")
# Create a new admin client for cleanup (don't rely on the existing one)
try:
cleanup_client = NextcloudClient.from_env()
for username in created_users:
try:
await cleanup_client.users.delete_user(userid=username)
print(f" ✓ Deleted user '{username}'")
except Exception as e:
logger.warning(f"Failed to delete user {username}: {e}")
print("✓ Cleanup complete\n")
except Exception as e:
logger.error(f"Error during user cleanup: {e}")
print(
"⚠️ Failed to cleanup users. Please run cleanup script manually.\n"
)
elif created_users:
print(
f"\n⚠️ {len(created_users)} test users were NOT deleted (cleanup=False)"
)
print(f"Users: {', '.join(created_users)}\n")
return metrics
@click.command()
@click.option(
"--users",
"-u",
type=int,
default=2,
show_default=True,
help="Number of concurrent users to create dynamically",
)
@click.option(
"--duration",
"-d",
type=float,
default=30.0,
show_default=True,
help="Test duration in seconds",
)
@click.option(
"--warmup",
"-w",
type=float,
default=5.0,
show_default=True,
help="Warmup duration before collecting metrics (seconds)",
)
@click.option(
"--url",
default="http://localhost:8001/mcp",
show_default=True,
help="MCP OAuth server URL",
)
@click.option(
"--output",
"-o",
type=click.Path(),
help="Output file for JSON results (optional)",
)
@click.option(
"--workload",
type=click.Choice(["mixed", "sharing", "collaboration", "baseline"]),
default="mixed",
show_default=True,
help="Workload type to execute",
)
@click.option(
"--user-prefix",
default="loadtest",
show_default=True,
help="Prefix for dynamically created usernames",
)
@click.option(
"--cleanup/--no-cleanup",
default=True,
show_default=True,
help="Delete created users after benchmark",
)
@click.option(
"--browser",
type=click.Choice(["firefox", "chromium", "webkit"]),
default="firefox",
show_default=True,
help="Playwright browser type for OAuth automation",
)
@click.option(
"--headed",
is_flag=True,
help="Run browser in headed mode (visible window, useful for debugging)",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
help="Enable verbose logging",
)
def main(
users: int,
duration: float,
warmup: float,
url: str,
output: str | None,
workload: str,
user_prefix: str,
cleanup: bool,
browser: str,
headed: bool,
verbose: bool,
):
"""
OAuth Multi-User Load Testing for Nextcloud MCP Server.
Dynamically creates N users, authenticates them via OAuth using Playwright
browser automation, and simulates realistic multi-user scenarios with
coordinated workflows like note sharing, collaborative editing, and file operations.
Examples:
# 2 users, 30-second test (default settings)
uv run python -m tests.load.oauth_benchmark
# 4 users, 60-second test with mixed workload
uv run python -m tests.load.oauth_benchmark --users 4 --duration 60
# 10 users, 5-minute sharing-focused test
uv run python -m tests.load.oauth_benchmark -u 10 -d 300 --workload sharing
# Export results to JSON
uv run python -m tests.load.oauth_benchmark -u 5 -d 120 --output results.json
# Custom user prefix and keep users after benchmark
uv run python -m tests.load.oauth_benchmark -u 3 --user-prefix mytest --no-cleanup
# Debug with visible browser (headed mode)
uv run python -m tests.load.oauth_benchmark -u 2 -d 10 --headed --verbose
Requirements:
- docker-compose up (mcp-oauth container running on port 8001)
- NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD env vars set
- Playwright browser installed: uv run playwright install firefox
"""
if verbose:
logging.getLogger().setLevel(logging.DEBUG)
logging.getLogger("tests.load").setLevel(logging.DEBUG)
async def run():
# Run benchmark
metrics = await run_oauth_benchmark(
num_users=users,
duration=duration,
mcp_url=url,
warmup=warmup,
user_prefix=user_prefix,
cleanup=cleanup,
browser_type=browser,
headed=headed,
)
# Print report
metrics.print_report()
# Export to JSON if requested
if output:
with open(output, "w") as f:
json.dump(metrics.to_dict(), f, indent=2)
print(f"Results exported to: {output}")
try:
anyio.run(run)
except KeyboardInterrupt:
print("\nBenchmark interrupted by user")
sys.exit(130)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
if verbose:
raise
sys.exit(1)
if __name__ == "__main__":
main()
+329
View File
@@ -0,0 +1,329 @@
"""
Enhanced metrics collection for OAuth multi-user load testing.
Extends the base BenchmarkMetrics to track per-user statistics,
workflow completion rates, and cross-user operation latencies.
"""
import statistics
from collections import Counter, defaultdict
from typing import Any
from tests.load.oauth_workloads import WorkflowResult
class OAuthBenchmarkMetrics:
"""
Enhanced metrics for OAuth multi-user load testing.
Tracks:
- Per-user operation counts and latencies
- Workflow completion rates and timings
- Cross-user operation metrics
- Step-by-step workflow breakdowns
"""
def __init__(self):
# Base metrics
self.start_time: float | None = None
self.end_time: float | None = None
# Per-user tracking
self.user_operations: dict[str, list[dict[str, Any]]] = defaultdict(list)
self.user_operation_counts: dict[str, Counter] = defaultdict(Counter)
self.user_errors: dict[str, Counter] = defaultdict(Counter)
# Workflow tracking
self.workflows: list[WorkflowResult] = []
self.workflow_counts: Counter = Counter()
self.workflow_successes: Counter = Counter()
self.workflow_durations: dict[str, list[float]] = defaultdict(list)
# Baseline operations (non-workflow)
self.baseline_operations: list[dict[str, Any]] = []
def start(self):
"""Mark the start of the benchmark."""
import time
self.start_time = time.time()
def stop(self):
"""Mark the end of the benchmark."""
import time
self.end_time = time.time()
@property
def duration(self) -> float:
"""Total benchmark duration in seconds."""
if self.start_time is None or self.end_time is None:
return 0.0
return self.end_time - self.start_time
def add_workflow_result(self, result: WorkflowResult):
"""
Add a workflow execution result.
Args:
result: WorkflowResult from workflow execution
"""
self.workflows.append(result)
self.workflow_counts[result.workflow_name] += 1
if result.success:
self.workflow_successes[result.workflow_name] += 1
self.workflow_durations[result.workflow_name].append(result.total_duration)
# Track per-user operations from workflow steps
for step in result.steps:
self.user_operation_counts[step.user][step.step_name] += 1
if not step.success:
self.user_errors[step.user][step.step_name] += 1
self.user_operations[step.user].append(
{
"type": "workflow_step",
"workflow": result.workflow_name,
"step": step.step_name,
"success": step.success,
"duration": step.duration,
"error": step.error,
}
)
def add_baseline_operation(self, operation: dict[str, Any]):
"""
Add a baseline (non-workflow) operation result.
Args:
operation: Dict with keys: type, operation, user, success, duration, error (optional)
"""
self.baseline_operations.append(operation)
user = operation.get("user", "unknown")
op_name = operation.get("operation", "unknown")
success = operation.get("success", False)
self.user_operation_counts[user][op_name] += 1
if not success:
self.user_errors[user][op_name] += 1
self.user_operations[user].append(operation)
def get_user_stats(self) -> dict[str, dict[str, Any]]:
"""
Get per-user statistics.
Returns:
Dict mapping username to their stats
"""
stats = {}
for user, operations in self.user_operations.items():
total_ops = len(operations)
successful_ops = sum(1 for op in operations if op.get("success", False))
durations = [op["duration"] for op in operations if "duration" in op]
stats[user] = {
"total_operations": total_ops,
"successful_operations": successful_ops,
"failed_operations": total_ops - successful_ops,
"success_rate": (successful_ops / total_ops * 100)
if total_ops > 0
else 0.0,
"latency": self._calculate_latency_stats(durations),
"operations_breakdown": dict(self.user_operation_counts[user]),
"errors_breakdown": dict(self.user_errors[user]),
}
return stats
def get_workflow_stats(self) -> dict[str, dict[str, Any]]:
"""
Get workflow execution statistics.
Returns:
Dict mapping workflow name to its stats
"""
stats = {}
for workflow_name in self.workflow_counts:
total = self.workflow_counts[workflow_name]
successes = self.workflow_successes[workflow_name]
durations = self.workflow_durations[workflow_name]
# Calculate per-step latencies
step_latencies = defaultdict(list)
for workflow in self.workflows:
if workflow.workflow_name == workflow_name:
for step in workflow.steps:
if step.success:
step_latencies[step.step_name].append(step.duration)
step_stats = {}
for step_name, latencies in step_latencies.items():
if latencies:
step_stats[step_name] = self._calculate_latency_stats(latencies)
stats[workflow_name] = {
"total_executions": total,
"successful_executions": successes,
"failed_executions": total - successes,
"success_rate": (successes / total * 100) if total > 0 else 0.0,
"latency": self._calculate_latency_stats(durations),
"step_latencies": step_stats,
}
return stats
def get_baseline_stats(self) -> dict[str, Any]:
"""
Get statistics for baseline operations.
Returns:
Dict with baseline operation stats
"""
if not self.baseline_operations:
return {
"total_operations": 0,
"success_rate": 0.0,
"latency": self._calculate_latency_stats([]),
}
total = len(self.baseline_operations)
successes = sum(
1 for op in self.baseline_operations if op.get("success", False)
)
durations = [
op["duration"] for op in self.baseline_operations if "duration" in op
]
# Per-operation breakdown
operation_counts = Counter()
operation_errors = Counter()
for op in self.baseline_operations:
op_name = op.get("operation", "unknown")
operation_counts[op_name] += 1
if not op.get("success", False):
operation_errors[op_name] += 1
return {
"total_operations": total,
"successful_operations": successes,
"failed_operations": total - successes,
"success_rate": (successes / total * 100) if total > 0 else 0.0,
"latency": self._calculate_latency_stats(durations),
"operations_breakdown": dict(operation_counts),
"errors_breakdown": dict(operation_errors),
}
def _calculate_latency_stats(self, durations: list[float]) -> dict[str, float]:
"""Calculate latency statistics from a list of durations."""
if not durations:
return {
"min": 0.0,
"max": 0.0,
"mean": 0.0,
"median": 0.0,
"p90": 0.0,
"p95": 0.0,
"p99": 0.0,
}
sorted_durations = sorted(durations)
def percentile(data: list[float], p: float) -> float:
k = (len(data) - 1) * p
f = int(k)
c = f + 1
if c >= len(data):
return data[-1]
return data[f] + (k - f) * (data[c] - data[f])
return {
"min": min(durations),
"max": max(durations),
"mean": statistics.mean(durations),
"median": statistics.median(durations),
"p90": percentile(sorted_durations, 0.90),
"p95": percentile(sorted_durations, 0.95),
"p99": percentile(sorted_durations, 0.99),
}
def to_dict(self) -> dict[str, Any]:
"""Convert metrics to dictionary for JSON export."""
return {
"summary": {
"duration": self.duration,
"total_workflows": len(self.workflows),
"total_baseline_ops": len(self.baseline_operations),
"total_users": len(self.user_operations),
},
"workflows": self.get_workflow_stats(),
"baseline": self.get_baseline_stats(),
"users": self.get_user_stats(),
}
def print_report(self):
"""Print human-readable benchmark report."""
print("\n" + "=" * 80)
print("OAUTH MULTI-USER BENCHMARK RESULTS")
print("=" * 80)
# Summary
print(f"\nDuration: {self.duration:.2f}s")
print(f"Total Users: {len(self.user_operations)}")
print(f"Total Workflows Executed: {len(self.workflows)}")
print(f"Total Baseline Operations: {len(self.baseline_operations)}")
# Workflow Stats
if self.workflows:
print("\n" + "-" * 80)
print("WORKFLOW STATISTICS")
print("-" * 80)
print(
f"{'Workflow':<30} {'Total':>8} {'Success':>8} {'Rate':>8} {'P50':>10} {'P95':>10}"
)
print("-" * 80)
workflow_stats = self.get_workflow_stats()
for name, stats in sorted(workflow_stats.items()):
latency = stats["latency"]
print(
f"{name:<30} {stats['total_executions']:>8} "
f"{stats['successful_executions']:>8} "
f"{stats['success_rate']:>7.1f}% "
f"{latency['median']:>9.4f}s {latency['p95']:>9.4f}s"
)
# Per-User Stats
print("\n" + "-" * 80)
print("PER-USER STATISTICS")
print("-" * 80)
print(
f"{'User':<20} {'Total Ops':>10} {'Success':>10} {'Errors':>8} {'Rate':>8} {'P50':>10}"
)
print("-" * 80)
user_stats = self.get_user_stats()
for username, stats in sorted(user_stats.items()):
latency = stats["latency"]
print(
f"{username:<20} {stats['total_operations']:>10} "
f"{stats['successful_operations']:>10} "
f"{stats['failed_operations']:>8} "
f"{stats['success_rate']:>7.1f}% "
f"{latency['median']:>9.4f}s"
)
# Baseline Stats
if self.baseline_operations:
print("\n" + "-" * 80)
print("BASELINE OPERATIONS")
print("-" * 80)
baseline = self.get_baseline_stats()
print(f"Total Operations: {baseline['total_operations']}")
print(f"Success Rate: {baseline['success_rate']:.1f}%")
latency = baseline["latency"]
print(
f"Latency: min={latency['min']:.4f}s, p50={latency['median']:.4f}s, "
f"p95={latency['p95']:.4f}s, max={latency['max']:.4f}s"
)
print("=" * 80 + "\n")
+485
View File
@@ -0,0 +1,485 @@
"""
OAuth User Pool Management for Load Testing.
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
"""
import asyncio
import logging
from dataclasses import dataclass
from typing import Any
import httpx
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
logger = logging.getLogger(__name__)
@dataclass
class UserConfig:
"""Configuration for a single test user."""
username: str
password: str
display_name: str
email: str
groups: list[str]
@dataclass
class UserProfile:
"""Profile for an OAuth-authenticated user."""
username: str
password: str
token: str
session: ClientSession | None = None
streamable_context: Any | None = None # Store for proper cleanup
operation_count: int = 0
error_count: int = 0
class OAuthUserPool:
"""
Manages a pool of OAuth-authenticated users for load testing.
Handles token acquisition, session management, and user lifecycle.
"""
def __init__(
self,
admin_client: Any, # NextcloudClient with admin credentials
client_id: str,
client_secret: str,
callback_url: str,
token_endpoint: str,
authorization_endpoint: str,
):
self.admin_client = admin_client # For user management
self.nextcloud_host = str(admin_client._client.base_url)
self.client_id = client_id
self.client_secret = client_secret
self.callback_url = callback_url
self.token_endpoint = token_endpoint
self.authorization_endpoint = authorization_endpoint
self.users: dict[str, UserProfile] = {}
self._http_client: httpx.AsyncClient | None = None
async def __aenter__(self):
"""Initialize HTTP client."""
self._http_client = httpx.AsyncClient(verify=False, timeout=30.0)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Cleanup HTTP client."""
if self._http_client:
await self._http_client.aclose()
async def acquire_token(self, username: str, password: str, auth_code: str) -> str:
"""
Exchange authorization code for OAuth access token.
Args:
username: Username for logging
password: Password (for logging/debugging)
auth_code: Authorization code from OAuth flow
Returns:
OAuth access token
"""
logger.info(f"Exchanging auth code for access token (user: {username})...")
if not self._http_client:
raise RuntimeError(
"HTTP client not initialized - use async context manager"
)
# Exchange authorization code for access token
token_response = await self._http_client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": self.callback_url,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access token in response for {username}")
logger.info(f"Successfully acquired OAuth token for {username}")
return access_token
async def add_user(self, username: str, password: str, token: str) -> UserProfile:
"""
Add a user to the pool with their OAuth token.
Args:
username: Username
password: Password (for future re-auth if needed)
token: OAuth access token
Returns:
UserProfile for the added user
"""
if username in self.users:
logger.warning(f"User {username} already in pool, updating token")
profile = UserProfile(username=username, password=password, token=token)
self.users[username] = profile
logger.info(f"Added user {username} to pool (total: {len(self.users)})")
return profile
async def create_user_session(
self, username: str, mcp_url: str = "http://localhost:8001/mcp"
) -> ClientSession:
"""
Create an MCP client session for a user.
Args:
username: Username to create session for
mcp_url: MCP server URL
Returns:
Initialized ClientSession
Raises:
KeyError: If user not in pool
"""
if username not in self.users:
raise KeyError(f"User {username} not in pool")
profile = self.users[username]
# Create streamable HTTP connection with OAuth token in Authorization header
# This matches the pattern from tests/conftest.py create_mcp_client_session()
headers = {"Authorization": f"Bearer {profile.token}"}
streamable_context = streamablehttp_client(mcp_url, headers=headers)
try:
read_stream, write_stream, _ = await streamable_context.__aenter__()
session = ClientSession(read_stream, write_stream)
await session.__aenter__()
await session.initialize()
# Store both session and context for proper cleanup
profile.session = session
profile.streamable_context = streamable_context
logger.info(f"Created MCP session for {username}")
return session
except Exception as e:
# Clean up streamable context if session creation failed
try:
await streamable_context.__aexit__(None, None, None)
except Exception as cleanup_error:
logger.debug(f"Error during cleanup: {cleanup_error}")
raise e
async def close_user_session(self, username: str):
"""Close the MCP session for a user."""
if username not in self.users:
return
profile = self.users[username]
# Close ClientSession
if profile.session:
try:
await profile.session.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing session for {username}: {e}")
profile.session = None
# Close streamable context
if profile.streamable_context:
try:
await profile.streamable_context.__aexit__(None, None, None)
except Exception as e:
logger.debug(f"Error closing streamable context for {username}: {e}")
profile.streamable_context = None
async def close_all_sessions(self):
"""Close all user sessions."""
for username in list(self.users.keys()):
await self.close_user_session(username)
def get_user(self, username: str) -> UserProfile:
"""Get user profile by username."""
if username not in self.users:
raise KeyError(f"User {username} not in pool")
return self.users[username]
def get_all_users(self) -> list[UserProfile]:
"""Get all user profiles."""
return list(self.users.values())
def record_operation(self, username: str, success: bool = True):
"""Record an operation for user stats."""
if username in self.users:
self.users[username].operation_count += 1
if not success:
self.users[username].error_count += 1
def get_stats(self) -> dict[str, dict[str, int | float]]:
"""Get per-user operation statistics."""
return {
username: {
"operations": profile.operation_count,
"errors": profile.error_count,
"success_rate": (
(profile.operation_count - profile.error_count)
/ max(profile.operation_count, 1)
* 100
),
}
for username, profile in self.users.items()
}
async def create_nextcloud_user(
self,
username: str,
password: str,
display_name: str | None = None,
email: str | None = None,
) -> UserConfig:
"""
Create a Nextcloud user via the Users API.
Args:
username: Username for the new user
password: Password for the new user
display_name: Optional display name
email: Optional email address
Returns:
UserConfig for the created user
Raises:
HTTPStatusError: If user creation fails
"""
logger.info(f"Creating Nextcloud user: {username}")
await self.admin_client.users.create_user(
userid=username,
password=password,
display_name=display_name or username,
email=email or f"{username}@benchmark.local",
)
logger.info(f"Successfully created Nextcloud user: {username}")
return UserConfig(
username=username,
password=password,
display_name=display_name or username,
email=email or f"{username}@benchmark.local",
groups=[],
)
async def delete_nextcloud_user(self, username: str):
"""
Delete a Nextcloud user via the Users API.
Args:
username: Username to delete
"""
logger.info(f"Deleting Nextcloud user: {username}")
try:
await self.admin_client.users.delete_user(userid=username)
logger.info(f"Successfully deleted Nextcloud user: {username}")
except Exception as e:
logger.warning(f"Failed to delete user {username}: {e}")
async def acquire_token_playwright(
self,
browser: Any,
username: str,
password: str,
state: str,
auth_states: dict[str, str],
) -> str:
"""
Acquire OAuth token via Playwright browser automation.
Based on conftest.py playwright_oauth_token fixture.
Automates the full OAuth flow:
1. Navigate to authorization URL
2. Fill login form
3. Handle OAuth consent
4. Wait for callback server to receive auth code
5. Exchange code for access token
Args:
browser: Playwright browser instance
username: Username to authenticate
password: Password for the user
state: Unique state parameter for this OAuth flow
auth_states: Dict mapping state -> auth_code (shared with callback server)
Returns:
OAuth access token
Raises:
TimeoutError: If callback not received within timeout
ValueError: If token exchange fails
"""
import time
from urllib.parse import quote
logger.info(f"Starting Playwright OAuth flow for {username}...")
logger.debug(f"Using state: {state[:16]}...")
# Construct authorization URL
auth_url = (
f"{self.authorization_endpoint}?"
f"response_type=code&"
f"client_id={self.client_id}&"
f"redirect_uri={quote(self.callback_url, safe='')}&"
f"state={state}&"
f"scope=openid%20profile%20email"
)
# Browser automation
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
# Navigate to authorization URL
logger.debug("Navigating to authorization URL...")
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
current_url = page.url
# Login if needed
if "/login" in current_url or "/index.php/login" in current_url:
logger.info(f"Logging in as {username}...")
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=30000)
current_url = page.url
logger.info("Login completed")
# Handle OAuth consent if present
try:
authorize_button = await page.query_selector(
'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]'
)
if authorize_button:
logger.info("Authorizing OAuth client...")
await authorize_button.click()
await page.wait_for_load_state("networkidle", timeout=10000)
except Exception as e:
logger.debug(f"No authorization needed: {e}")
# Wait for callback server to receive auth code
logger.info("Waiting for OAuth callback...")
timeout_seconds = 30
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > timeout_seconds:
screenshot_path = f"/tmp/oauth_timeout_{username}.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Screenshot saved to {screenshot_path}")
raise TimeoutError(
f"Timeout waiting for OAuth callback for {username}"
)
await asyncio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Received auth code for {username}")
finally:
await context.close()
# Exchange code for token
logger.info(f"Exchanging auth code for access token ({username})...")
token_response = await self._http_client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": self.callback_url,
"client_id": self.client_id,
"client_secret": self.client_secret,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access token for {username}: {token_data}")
logger.info(f"Successfully acquired OAuth token for {username}")
return access_token
class UserSessionWrapper:
"""
Wrapper for a user-specific MCP session with operation tracking.
Provides a convenient interface for executing operations as a specific user.
"""
def __init__(self, username: str, session: ClientSession, pool: OAuthUserPool):
self.username = username
self.session = session
self.pool = pool
async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any:
"""
Call an MCP tool and record the operation.
Args:
tool_name: Name of the tool to call
arguments: Tool arguments
Returns:
Tool result
"""
try:
result = await self.session.call_tool(tool_name, arguments)
self.pool.record_operation(self.username, success=True)
return result
except Exception:
self.pool.record_operation(self.username, success=False)
raise
async def read_resource(self, uri: str) -> Any:
"""
Read an MCP resource and record the operation.
Args:
uri: Resource URI
Returns:
Resource data
"""
try:
result = await self.session.read_resource(uri)
self.pool.record_operation(self.username, success=True)
return result
except Exception:
self.pool.record_operation(self.username, success=False)
raise
def generate_secure_password(length: int = 20) -> str:
"""Generate a secure random password."""
import secrets
import string
alphabet = string.ascii_letters + string.digits + "!@#$%^&*()"
return "".join(secrets.choice(alphabet) for _ in range(length))
+506
View File
@@ -0,0 +1,506 @@
"""
Multi-User Workflow Definitions for OAuth Load Testing.
Defines coordinated workflows that span multiple users, simulating realistic
collaborative scenarios like note sharing, file collaboration, and permission management.
"""
import asyncio
import json
import logging
import random
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable
from tests.load.oauth_pool import UserSessionWrapper
logger = logging.getLogger(__name__)
@dataclass
class WorkflowStepResult:
"""Result of a single workflow step."""
step_name: str
user: str
success: bool
duration: float
error: str | None = None
data: dict[str, Any] = field(default_factory=dict)
@dataclass
class WorkflowResult:
"""Result of a complete workflow execution."""
workflow_name: str
success: bool
total_duration: float
steps: list[WorkflowStepResult]
participants: list[str]
error: str | None = None
@property
def steps_completed(self) -> int:
"""Count of successfully completed steps."""
return sum(1 for step in self.steps if step.success)
@property
def step_latencies(self) -> dict[str, float]:
"""Map of step names to their durations."""
return {step.step_name: step.duration for step in self.steps}
class Workflow(ABC):
"""
Base class for multi-user workflows.
A workflow represents a coordinated sequence of operations across multiple users,
such as creating and sharing a note, collaborative editing, or permission management.
"""
def __init__(self, name: str):
self.name = name
self.steps: list[WorkflowStepResult] = []
self.start_time: float | None = None
@abstractmethod
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""
Execute the workflow with the given users.
Args:
users: List of UserSessionWrapper instances to use in the workflow
Returns:
WorkflowResult with execution details
"""
pass
async def _execute_step(
self,
step_name: str,
user: UserSessionWrapper,
operation: Callable[..., Awaitable[Any]],
**kwargs,
) -> WorkflowStepResult:
"""
Execute a single workflow step with timing and error handling.
Args:
step_name: Name of the step for reporting
user: User executing the step
operation: Async callable to execute
**kwargs: Arguments to pass to the operation
Returns:
WorkflowStepResult
"""
start = time.time()
try:
result = await operation(**kwargs)
duration = time.time() - start
step_result = WorkflowStepResult(
step_name=step_name,
user=user.username,
success=True,
duration=duration,
data={"result": result} if result else {},
)
self.steps.append(step_result)
return step_result
except Exception as e:
duration = time.time() - start
logger.error(f"Step {step_name} failed for user {user.username}: {e}")
step_result = WorkflowStepResult(
step_name=step_name,
user=user.username,
success=False,
duration=duration,
error=str(e),
)
self.steps.append(step_result)
return step_result
def _finish(self, success: bool, error: str | None = None) -> WorkflowResult:
"""
Finalize workflow and create result.
Args:
success: Whether the overall workflow succeeded
error: Optional error message
Returns:
WorkflowResult
"""
duration = time.time() - self.start_time if self.start_time else 0.0
participants = list(set(step.user for step in self.steps))
return WorkflowResult(
workflow_name=self.name,
success=success,
total_duration=duration,
steps=self.steps,
participants=participants,
error=error,
)
class NoteShareWorkflow(Workflow):
"""
Workflow: User A creates a note and shares it with User B, who then reads it.
Steps:
1. User A creates a note
2. User A shares the note with User B (read-only)
3. User B lists their shared notes (verify propagation)
4. User B reads the shared note
"""
def __init__(self):
super().__init__("note_share")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute note sharing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
user_a, user_b = users[0], users[1]
unique_id = uuid.uuid4().hex[:8]
try:
# Step 1: User A creates note
create_result = await self._execute_step(
"create_note",
user_a,
lambda: user_a.call_tool(
"nc_notes_create_note",
{
"title": f"Shared Note {unique_id}",
"content": f"Content for workflow test {unique_id}",
"category": "Workflows",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create note")
# Extract note ID
note_data = json.loads(create_result.data["result"].content[0].text)
note_id = note_data["id"]
# Step 2: User A shares note with User B
# Note: Sharing files/notes requires using WebDAV path
# Create a file first, then share it
share_result = await self._execute_step(
"share_note",
user_a,
lambda: user_a.call_tool(
"nc_share_create",
{
"path": f"/Notes/{note_data['category']}/{note_data['title']}.txt",
"share_with": user_b.username,
"share_type": 0, # User share
"permissions": 1, # Read-only
},
),
)
if not share_result.success:
logger.warning("Share creation failed, continuing anyway")
# Step 3: User B lists shares (measure propagation)
await self._execute_step(
"list_shared_with_me",
user_b,
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
)
# Step 4: User B reads the note
await self._execute_step(
"read_shared_note",
user_b,
lambda: user_b.call_tool("nc_notes_get_note", {"note_id": note_id}),
)
# Cleanup: Delete the note
await user_a.call_tool("nc_notes_delete_note", {"note_id": note_id})
return self._finish(success=True)
except Exception as e:
logger.error(f"Note share workflow failed: {e}")
return self._finish(False, error=str(e))
class CollaborativeEditWorkflow(Workflow):
"""
Workflow: Multiple users edit the same note concurrently.
Steps:
1. User A creates a note
2. User A shares note with Users B, C (edit permissions)
3. All users read the note simultaneously
4. All users update the note simultaneously (test concurrent edits)
5. User A verifies final state
"""
def __init__(self):
super().__init__("collaborative_edit")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute collaborative editing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
owner = users[0]
collaborators = users[1:]
unique_id = uuid.uuid4().hex[:8]
try:
# Step 1: Owner creates note
create_result = await self._execute_step(
"create_note",
owner,
lambda: owner.call_tool(
"nc_notes_create_note",
{
"title": f"Collab Note {unique_id}",
"content": f"Initial content {unique_id}",
"category": "Collaboration",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create note")
note_data = json.loads(create_result.data["result"].content[0].text)
note_id = note_data["id"]
# Step 2: Read note concurrently by all users
read_tasks = []
for i, user in enumerate(users):
read_tasks.append(
self._execute_step(
f"concurrent_read_{i}",
user,
lambda uid=note_id: user.call_tool(
"nc_notes_get_note", {"note_id": uid}
),
)
)
await asyncio.gather(*read_tasks)
# Step 3: Append content concurrently by all collaborators
append_tasks = []
for i, user in enumerate(collaborators):
append_tasks.append(
self._execute_step(
f"concurrent_append_{i}",
user,
lambda _=i, u=user: u.call_tool(
"nc_notes_append_content",
{
"note_id": note_id,
"content": f"Addition from {u.username} at {time.time()}",
},
),
)
)
await asyncio.gather(*append_tasks)
# Step 4: Owner verifies final state
await self._execute_step(
"verify_final_state",
owner,
lambda: owner.call_tool("nc_notes_get_note", {"note_id": note_id}),
)
# Cleanup
await owner.call_tool("nc_notes_delete_note", {"note_id": note_id})
return self._finish(success=True)
except Exception as e:
logger.error(f"Collaborative edit workflow failed: {e}")
return self._finish(False, error=str(e))
class FileShareAndDownloadWorkflow(Workflow):
"""
Workflow: User A uploads a file, shares it with User B, who then downloads it.
Steps:
1. User A creates a file via WebDAV
2. User A shares the file with User B (read-only)
3. User B lists their shares
4. User B reads/downloads the file
"""
def __init__(self):
super().__init__("file_share_download")
async def execute(self, users: list[UserSessionWrapper]) -> WorkflowResult:
"""Execute file sharing workflow."""
self.start_time = time.time()
if len(users) < 2:
return self._finish(False, error="Requires at least 2 users")
user_a, user_b = users[0], users[1]
unique_id = uuid.uuid4().hex[:8]
file_path = f"/LoadTest_{unique_id}.txt"
try:
# Step 1: User A creates a file
content = f"Test file content {unique_id}\nCreated for workflow testing"
create_result = await self._execute_step(
"create_file",
user_a,
lambda: user_a.call_tool(
"nc_webdav_put_file",
{
"path": file_path,
"content": content,
"content_type": "text/plain",
},
),
)
if not create_result.success:
return self._finish(False, error="Failed to create file")
# Step 2: User A shares file with User B
share_result = await self._execute_step(
"share_file",
user_a,
lambda: user_a.call_tool(
"nc_share_create",
{
"path": file_path,
"share_with": user_b.username,
"share_type": 0,
"permissions": 1, # Read-only
},
),
)
if not share_result.success:
logger.warning("File share failed, continuing")
# Step 3: User B lists shared files
_ = await self._execute_step(
"list_shares",
user_b,
lambda: user_b.call_tool("nc_share_list", {"shared_with_me": True}),
)
# Step 4: User B downloads the file
_ = await self._execute_step(
"download_file",
user_b,
lambda: user_b.call_tool("nc_webdav_get_file", {"path": file_path}),
)
# Cleanup
await user_a.call_tool("nc_webdav_delete", {"path": file_path})
return self._finish(success=True)
except Exception as e:
logger.error(f"File share workflow failed: {e}")
return self._finish(False, error=str(e))
class MixedOAuthWorkload:
"""
Mixed workload combining baseline operations and coordinated workflows.
Distribution:
- 50% Baseline operations (individual user CRUD)
- 30% Note sharing workflows
- 15% Collaborative editing workflows
- 5% File sharing workflows
"""
def __init__(self, users: list[UserSessionWrapper]):
self.users = users
self.workflows = {
"note_share": NoteShareWorkflow(),
"collaborative_edit": CollaborativeEditWorkflow(),
"file_share": FileShareAndDownloadWorkflow(),
}
async def run_operation(self) -> WorkflowResult | dict[str, Any]:
"""
Execute one random operation (baseline or workflow).
Returns:
WorkflowResult for workflows, dict for baseline operations
"""
rand = random.random()
# 50% baseline operations (single-user)
if rand < 0.50:
return await self._run_baseline_operation()
# 30% note sharing
elif rand < 0.80:
users = random.sample(self.users, min(2, len(self.users)))
return await self.workflows["note_share"].execute(users)
# 15% collaborative editing
elif rand < 0.95:
users = random.sample(self.users, min(len(self.users), 3))
return await self.workflows["collaborative_edit"].execute(users)
# 5% file sharing
else:
users = random.sample(self.users, min(2, len(self.users)))
return await self.workflows["file_share"].execute(users)
async def _run_baseline_operation(self) -> dict[str, Any]:
"""Run a baseline single-user operation."""
user = random.choice(self.users)
operations = [
(
"search_notes",
lambda: user.call_tool("nc_notes_search_notes", {"query": ""}),
),
("list_files", lambda: user.call_tool("nc_webdav_list", {"path": "/"})),
("get_capabilities", lambda: user.read_resource("nc://capabilities")),
]
op_name, operation = random.choice(operations)
start = time.time()
try:
await operation()
duration = time.time() - start
return {
"type": "baseline",
"operation": op_name,
"user": user.username,
"success": True,
"duration": duration,
}
except Exception as e:
duration = time.time() - start
return {
"type": "baseline",
"operation": op_name,
"user": user.username,
"success": False,
"duration": duration,
"error": str(e),
}
+282
View File
@@ -0,0 +1,282 @@
"""
Workload definitions for load testing the MCP server.
Defines realistic operation mixes and individual operation functions.
"""
import logging
import random
import time
import uuid
from mcp import ClientSession
logger = logging.getLogger(__name__)
class OperationResult:
"""Result of a single operation execution."""
def __init__(
self,
operation: str,
success: bool,
duration: float,
error: str | None = None,
):
self.operation = operation
self.success = success
self.duration = duration
self.error = error
self.timestamp = time.time()
class WorkloadOperations:
"""Collection of MCP operations for load testing."""
def __init__(self, session: ClientSession):
self.session = session
self._created_notes: list[int] = []
self._created_boards: list[int] = []
async def get_capabilities(self) -> OperationResult:
"""Fetch server capabilities (lightweight operation)."""
start = time.time()
try:
await self.session.read_resource("nc://capabilities")
duration = time.time() - start
return OperationResult("get_capabilities", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("get_capabilities", False, duration, str(e))
async def list_notes(self) -> OperationResult:
"""List all notes (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_search_notes", {"query": ""})
duration = time.time() - start
return OperationResult("list_notes", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_notes", False, duration, str(e))
async def search_notes(self, query: str = "test") -> OperationResult:
"""Search notes by query (read operation with filtering)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_search_notes", {"query": query})
duration = time.time() - start
return OperationResult("search_notes", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("search_notes", False, duration, str(e))
async def create_note(self) -> OperationResult:
"""Create a new note (write operation)."""
start = time.time()
unique_id = uuid.uuid4().hex[:8]
try:
result = await self.session.call_tool(
"nc_notes_create_note",
{
"title": f"Load Test Note {unique_id}",
"content": f"Content for load test note {unique_id}",
"category": "LoadTesting",
},
)
duration = time.time() - start
# Track created note ID for cleanup
if result and len(result.content) > 0:
content = result.content[0]
if hasattr(content, "text"):
import json
note_data = json.loads(content.text)
note_id = note_data.get("id")
if note_id:
self._created_notes.append(note_id)
return OperationResult("create_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("create_note", False, duration, str(e))
async def get_note(self, note_id: int) -> OperationResult:
"""Get a specific note by ID (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_get_note", {"note_id": note_id})
duration = time.time() - start
return OperationResult("get_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("get_note", False, duration, str(e))
async def update_note(self, note_id: int, etag: str) -> OperationResult:
"""Update an existing note (write operation)."""
start = time.time()
try:
await self.session.call_tool(
"nc_notes_update_note",
{
"note_id": note_id,
"etag": etag,
"title": f"Updated Note {note_id}",
"content": f"Updated content at {time.time()}",
"category": "LoadTesting",
},
)
duration = time.time() - start
return OperationResult("update_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("update_note", False, duration, str(e))
async def delete_note(self, note_id: int) -> OperationResult:
"""Delete a note (write operation)."""
start = time.time()
try:
await self.session.call_tool("nc_notes_delete_note", {"note_id": note_id})
duration = time.time() - start
# Remove from tracking
if note_id in self._created_notes:
self._created_notes.remove(note_id)
return OperationResult("delete_note", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("delete_note", False, duration, str(e))
async def list_webdav_files(self, path: str = "/") -> OperationResult:
"""List files via WebDAV (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_webdav_list", {"path": path})
duration = time.time() - start
return OperationResult("list_webdav_files", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_webdav_files", False, duration, str(e))
async def list_calendars(self) -> OperationResult:
"""List calendars (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_calendar_list_calendars", {})
duration = time.time() - start
return OperationResult("list_calendars", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_calendars", False, duration, str(e))
async def list_deck_boards(self) -> OperationResult:
"""List deck boards (read operation)."""
start = time.time()
try:
await self.session.call_tool("nc_deck_list_boards", {})
duration = time.time() - start
return OperationResult("list_deck_boards", True, duration)
except Exception as e:
duration = time.time() - start
return OperationResult("list_deck_boards", False, duration, str(e))
async def cleanup(self):
"""Clean up any resources created during testing."""
logger.info(f"Cleaning up {len(self._created_notes)} test notes...")
for note_id in self._created_notes[:]:
try:
await self.delete_note(note_id)
except Exception as e:
logger.warning(f"Failed to delete note {note_id}: {e}")
class MixedWorkload:
"""
Realistic mixed workload simulating typical MCP server usage.
Operation distribution:
- 40% Notes read (list/get/search)
- 20% Notes write (create/update/delete)
- 15% Notes search
- 10% WebDAV operations
- 10% Calendar operations
- 5% Other (capabilities, deck)
"""
def __init__(self, operations: WorkloadOperations):
self.ops = operations
# Pre-create some notes for read/update operations
self._warmup_note_ids: list[tuple[int, str]] = []
async def warmup(self, count: int = 10):
"""Create initial notes for read/update operations."""
logger.info(f"Warming up with {count} test notes...")
for _ in range(count):
result = await self.ops.create_note()
if result.success and self.ops._created_notes:
note_id = self.ops._created_notes[-1]
# Get the note to fetch its etag
try:
get_result = await self.ops.session.call_tool(
"nc_notes_get_note", {"note_id": note_id}
)
if get_result and len(get_result.content) > 0:
import json
note_data = json.loads(get_result.content[0].text)
etag = note_data.get("etag", "")
self._warmup_note_ids.append((note_id, etag))
except Exception as e:
logger.warning(f"Failed to get etag for note {note_id}: {e}")
async def run_operation(self) -> OperationResult:
"""Execute one random operation based on the workload distribution."""
rand = random.random()
# 40% reads (list/get/search)
if rand < 0.40:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.list_notes()
elif op_rand < 0.8 and self._warmup_note_ids:
note_id, _ = random.choice(self._warmup_note_ids)
return await self.ops.get_note(note_id)
else:
return await self.ops.search_notes()
# 20% writes (create/update/delete)
elif rand < 0.60:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.create_note()
elif op_rand < 0.8 and self._warmup_note_ids:
note_id, etag = random.choice(self._warmup_note_ids)
return await self.ops.update_note(note_id, etag)
elif self.ops._created_notes and len(self.ops._created_notes) > 5:
# Only delete if we have enough notes
note_id = random.choice(self.ops._created_notes)
return await self.ops.delete_note(note_id)
else:
return await self.ops.create_note()
# 15% search
elif rand < 0.75:
queries = ["test", "load", "note", "content", ""]
return await self.ops.search_notes(random.choice(queries))
# 10% WebDAV
elif rand < 0.85:
return await self.ops.list_webdav_files()
# 10% Calendar
elif rand < 0.95:
return await self.ops.list_calendars()
# 5% Other
else:
op_rand = random.random()
if op_rand < 0.5:
return await self.ops.get_capabilities()
else:
return await self.ops.list_deck_boards()
View File
+476
View File
@@ -0,0 +1,476 @@
"""Integration tests for Calendar VTODO (task) MCP tools."""
import logging
from datetime import datetime, timedelta
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
async def test_mcp_todo_complete_workflow(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test complete todo workflow via MCP tools with verification via NextcloudClient."""
calendar_name = temporary_calendar
todo_uid = None
try:
# 1. Create todo via MCP
logger.info(f"Creating todo in {calendar_name} via MCP")
tomorrow = datetime.now() + timedelta(days=1)
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "MCP Test Task",
"description": "Test task created via MCP tools",
"status": "NEEDS-ACTION",
"priority": 3,
"due": tomorrow.strftime("%Y-%m-%dT18:00:00"),
"categories": "testing,mcp",
},
)
assert create_result.isError is False
# Extract UID from the result
result_data = create_result.content[0].text
import json
result_json = json.loads(result_data)
todo_uid = result_json["uid"]
logger.info(f"Created todo with UID: {todo_uid}")
# 2. Verify todo creation via client
todos = await nc_client.calendar.list_todos(calendar_name)
assert any(t["uid"] == todo_uid for t in todos)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
assert created_todo["summary"] == "MCP Test Task"
assert created_todo["status"] == "NEEDS-ACTION"
assert created_todo["priority"] == 3
# 3. List todos via MCP
logger.info(f"Listing todos in {calendar_name} via MCP")
list_result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name},
)
assert list_result.isError is False
list_data = json.loads(list_result.content[0].text)
assert "todos" in list_data
assert any(t["uid"] == todo_uid for t in list_data["todos"])
# 4. Update todo via MCP
logger.info(f"Updating todo {todo_uid} via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"summary": "MCP Test Task Updated",
"status": "IN-PROCESS",
"priority": 1,
"percent_complete": 50,
},
)
assert update_result.isError is False
# 5. Verify update via client
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
assert updated_todo["summary"] == "MCP Test Task Updated"
assert updated_todo["status"] == "IN-PROCESS"
assert updated_todo["priority"] == 1
assert updated_todo["percent_complete"] == 50
# 6. Delete todo via MCP
logger.info(f"Deleting todo {todo_uid} via MCP")
delete_result = await nc_mcp_client.call_tool(
"nc_calendar_delete_todo",
{"calendar_name": calendar_name, "todo_uid": todo_uid},
)
assert delete_result.isError is False
# 7. Verify deletion via client
todos = await nc_client.calendar.list_todos(calendar_name)
assert not any(t["uid"] == todo_uid for t in todos)
logger.info("Complete todo workflow test passed")
finally:
# Cleanup in case of failure
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_list_todos_with_filters(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test listing todos with various filters via MCP tools."""
calendar_name = temporary_calendar
created_uids = []
try:
# Create test todos with different properties
test_todos = [
{
"summary": "High Priority Task",
"status": "NEEDS-ACTION",
"priority": 1,
"categories": "urgent,work",
},
{
"summary": "In Progress Task",
"status": "IN-PROCESS",
"priority": 5,
"categories": "work",
},
{
"summary": "Low Priority Task",
"status": "NEEDS-ACTION",
"priority": 9,
"categories": "someday",
},
]
# Create todos via client
for todo_data in test_todos:
result = await nc_client.calendar.create_todo(calendar_name, todo_data)
created_uids.append(result["uid"])
# Test 1: Filter by status
logger.info("Testing filter by status")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "status": "NEEDS-ACTION"},
)
assert result.isError is False
import json
data = json.loads(result.content[0].text)
needs_action_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(needs_action_todos) == 2 # Two NEEDS-ACTION todos
# Test 2: Filter by priority
logger.info("Testing filter by minimum priority")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "min_priority": 1},
)
assert result.isError is False
data = json.loads(result.content[0].text)
high_priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(high_priority_todos) >= 1 # At least the priority 1 todo
# Test 3: Filter by categories
logger.info("Testing filter by categories")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "categories": "work"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
work_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(work_todos) >= 2 # Two todos with "work" category
# Test 4: Filter by summary text
logger.info("Testing filter by summary text")
result = await nc_mcp_client.call_tool(
"nc_calendar_list_todos",
{"calendar_name": calendar_name, "summary_contains": "Priority"},
)
assert result.isError is False
data = json.loads(result.content[0].text)
priority_todos = [t for t in data["todos"] if t["uid"] in created_uids]
assert len(priority_todos) == 2 # Two have "Priority" in summary (High, Low)
logger.info("List todos with filters test passed")
finally:
# Cleanup
for uid in created_uids:
try:
await nc_client.calendar.delete_todo(calendar_name, uid)
except Exception:
pass
async def test_mcp_search_todos_across_calendars(
nc_mcp_client: ClientSession,
nc_client: NextcloudClient,
temporary_calendar: str,
shared_calendar_2: str,
):
"""Test searching todos across multiple calendars via MCP tools.
Note: Uses two shared test calendars to avoid rate limiting.
"""
cal1_name = temporary_calendar # First shared test calendar
cal2_name = shared_calendar_2 # Second shared test calendar
created_uids = []
try:
# Use existing shared calendars (no creation needed, avoiding rate limits)
# Create todos in both calendars
result1 = await nc_client.calendar.create_todo(
cal1_name,
{
"summary": "Task in Calendar 1",
"status": "NEEDS-ACTION",
"categories": "cal1",
},
)
created_uids.append((cal1_name, result1["uid"]))
result2 = await nc_client.calendar.create_todo(
cal2_name,
{
"summary": "Task in Calendar 2",
"status": "IN-PROCESS",
"categories": "cal2",
},
)
created_uids.append((cal2_name, result2["uid"]))
# Search across all calendars via MCP
logger.info("Searching todos across all calendars via MCP")
search_result = await nc_mcp_client.call_tool(
"nc_calendar_search_todos",
{},
)
assert search_result.isError is False
import json
data = json.loads(search_result.content[0].text)
assert "todos" in data
# Verify both todos are in the results
found_uids = {t["uid"] for t in data["todos"]}
assert result1["uid"] in found_uids
assert result2["uid"] in found_uids
# Verify calendar_name is included
our_todos = [
t for t in data["todos"] if t["uid"] in [result1["uid"], result2["uid"]]
]
for todo in our_todos:
assert "calendar_name" in todo
assert todo["calendar_name"] in [cal1_name, cal2_name]
# Test search with status filter
logger.info("Searching with status filter via MCP")
search_result = await nc_mcp_client.call_tool(
"nc_calendar_search_todos",
{"status": "IN-PROCESS"},
)
assert search_result.isError is False
data = json.loads(search_result.content[0].text)
in_process_todos = [
t for t in data["todos"] if t["uid"] in [uid for _, uid in created_uids]
]
assert len(in_process_todos) >= 1
logger.info("Search todos across calendars test passed")
finally:
# Cleanup: Only delete todos, not calendars (they're reused/built-in)
for cal_name, uid in created_uids:
try:
await nc_client.calendar.delete_todo(cal_name, uid)
except Exception:
pass
async def test_mcp_todo_status_transitions(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test transitioning through different todo statuses via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
# Create todo
result = await nc_client.calendar.create_todo(
calendar_name,
{"summary": "Status Transition Test", "status": "NEEDS-ACTION"},
)
todo_uid = result["uid"]
# Transition: NEEDS-ACTION → IN-PROCESS
logger.info("Transitioning todo to IN-PROCESS via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"status": "IN-PROCESS",
"percent_complete": 25,
},
)
assert update_result.isError is False
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next(t for t in todos if t["uid"] == todo_uid)
assert todo["status"] == "IN-PROCESS"
assert todo["percent_complete"] == 25
# Transition: IN-PROCESS → COMPLETED
logger.info("Transitioning todo to COMPLETED via MCP")
completed_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"status": "COMPLETED",
"percent_complete": 100,
"completed": completed_time,
},
)
assert update_result.isError is False
todos = await nc_client.calendar.list_todos(calendar_name)
todo = next(t for t in todos if t["uid"] == todo_uid)
assert todo["status"] == "COMPLETED"
assert todo["percent_complete"] == 100
assert "completed" in todo
logger.info("Todo status transitions test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_with_dates(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and managing todos with date fields via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
now = datetime.now()
start_date = (now + timedelta(days=1)).strftime("%Y-%m-%dT09:00:00")
due_date = (now + timedelta(days=7)).strftime("%Y-%m-%dT17:00:00")
# Create todo with dates via MCP
logger.info("Creating todo with dates via MCP")
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "Task with Dates",
"description": "Test task with various date fields",
"status": "NEEDS-ACTION",
"dtstart": start_date,
"due": due_date,
},
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
# Verify dates via client
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
assert created_todo["summary"] == "Task with Dates"
assert "dtstart" in created_todo
assert "due" in created_todo
logger.info("Todo with dates test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
async def test_mcp_todo_categories(
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
):
"""Test creating and managing todos with categories via MCP tools."""
calendar_name = temporary_calendar
todo_uid = None
try:
# Create todo with multiple categories via MCP
logger.info("Creating todo with categories via MCP")
create_result = await nc_mcp_client.call_tool(
"nc_calendar_create_todo",
{
"calendar_name": calendar_name,
"summary": "Task with Categories",
"status": "NEEDS-ACTION",
"categories": "work,meeting,important,quarterly",
},
)
assert create_result.isError is False
import json
result_data = json.loads(create_result.content[0].text)
todo_uid = result_data["uid"]
# Verify categories via client
todos = await nc_client.calendar.list_todos(calendar_name)
created_todo = next(t for t in todos if t["uid"] == todo_uid)
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
# Update categories via MCP
logger.info("Updating todo categories via MCP")
update_result = await nc_mcp_client.call_tool(
"nc_calendar_update_todo",
{
"calendar_name": calendar_name,
"todo_uid": todo_uid,
"categories": "updated,new-category",
},
)
assert update_result.isError is False
# Verify updated categories
todos = await nc_client.calendar.list_todos(calendar_name)
updated_todo = next(t for t in todos if t["uid"] == todo_uid)
categories_str = updated_todo["categories"]
assert "updated" in categories_str
assert "new-category" in categories_str
logger.info("Todo categories test passed")
finally:
if todo_uid:
try:
await nc_client.calendar.delete_todo(calendar_name, todo_uid)
except Exception:
pass
@@ -0,0 +1,479 @@
"""
Integration tests for token introspection authorization.
These tests verify that the introspection endpoint properly enforces
authorization rules:
1. Client authentication is required (401 if missing)
2. Only the token owner can introspect its own tokens
3. Only the designated resource server can introspect tokens
4. Other clients cannot introspect tokens they don't own or aren't the audience for
"""
import asyncio
import logging
import os
import secrets
# Import helpers from conftest
import time
from typing import AsyncGenerator
from urllib.parse import quote
import httpx
import pytest
# Import from the root tests/ conftest.py using relative import
from ..conftest import _handle_oauth_consent_screen
logger = logging.getLogger(__name__)
@pytest.fixture(scope="module")
def nextcloud_host() -> str:
"""Get Nextcloud host from environment."""
host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
return host
@pytest.fixture(scope="module")
async def oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
"""Discover OIDC endpoints."""
async with httpx.AsyncClient(timeout=30.0) as client:
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
response = await client.get(discovery_url)
response.raise_for_status()
config = response.json()
return {
"token_endpoint": config["token_endpoint"],
"authorization_endpoint": config.get("authorization_endpoint"),
"introspection_endpoint": config.get("introspection_endpoint"),
"registration_endpoint": config.get("registration_endpoint"),
}
@pytest.fixture(scope="module")
async def test_oauth_clients(
nextcloud_host: str, oidc_endpoints: dict[str, str], oauth_callback_server
) -> AsyncGenerator[dict[str, tuple[str, str]], None]:
"""
Create multiple OAuth clients for introspection testing.
Returns a dict mapping client names to (client_id, client_secret) tuples.
"""
from nextcloud_mcp_server.auth.client_registration import register_client
clients = {}
registration_endpoint = oidc_endpoints["registration_endpoint"]
# Get the correct callback URL from the oauth_callback_server fixture
auth_states, callback_url = oauth_callback_server
# Create client A (will be the token owner)
logger.info("Creating OAuth client A for introspection testing")
client_a = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client A",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer", # Use opaque tokens for this test
)
clients["clientA"] = (client_a.client_id, client_a.client_secret)
logger.info(f"Created client A: {client_a.client_id[:16]}...")
# Create client B (will attempt to introspect client A's tokens)
logger.info("Creating OAuth client B for introspection testing")
client_b = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client B",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer",
)
clients["clientB"] = (client_b.client_id, client_b.client_secret)
logger.info(f"Created client B: {client_b.client_id[:16]}...")
# Create client C (third party, should not be able to introspect)
logger.info("Creating OAuth client C for introspection testing")
client_c = await register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
client_name="Introspection Test Client C",
redirect_uris=[callback_url],
scopes="openid profile email",
token_type="Bearer",
)
clients["clientC"] = (client_c.client_id, client_c.client_secret)
logger.info(f"Created client C: {client_c.client_id[:16]}...")
yield clients
# Cleanup is handled by Nextcloud - clients will be removed when tests are done
logger.info("Test OAuth clients fixture complete")
@pytest.mark.integration
async def test_introspection_requires_client_authentication(
oidc_endpoints: dict[str, str],
):
"""
Test that the introspection endpoint requires client authentication.
Expected: 401 UNAUTHORIZED when credentials are missing or invalid.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: No credentials
response = await client.post(
introspection_endpoint,
data={"token": "some_token"},
)
assert response.status_code == 401, "Should return 401 without credentials"
data = response.json()
assert data.get("error") == "invalid_client"
# Test 2: Invalid credentials
response = await client.post(
introspection_endpoint,
data={"token": "some_token"},
auth=("invalid_client", "invalid_secret"),
)
assert response.status_code == 401, "Should return 401 with invalid credentials"
data = response.json()
logger.info(f"Invalid client response: {data}")
# Response may be either {"error": "invalid_client"} or {"message": "..."}
# Both are acceptable as long as we get 401
assert "error" in data or "message" in data, "Should return error information"
async def _obtain_token_for_client(
browser,
oauth_callback_server,
client_id: str,
client_secret: str,
token_endpoint: str,
authorization_endpoint: str,
scope: str = "openid profile email",
resource: str | None = None,
) -> str:
"""
Helper to obtain an OAuth token using existing callback server and playwright automation.
Reuses the pattern from conftest.py's playwright_oauth_token fixture.
"""
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
# Get callback server from fixture
auth_states, callback_url = oauth_callback_server
# Generate unique state parameter
state = secrets.token_urlsafe(32)
# Construct authorization URL
auth_url_parts = [
f"{authorization_endpoint}?",
"response_type=code&",
f"client_id={client_id}&",
f"redirect_uri={quote(callback_url, safe='')}&",
f"state={state}&",
f"scope={quote(scope, safe='')}",
]
if resource:
auth_url_parts.append(f"&resource={quote(resource, safe='')}")
auth_url = "".join(auth_url_parts)
logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}")
if resource:
logger.info(f" Resource parameter: {resource[:16]}...")
# Browser automation (same pattern as conftest.py)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
try:
logger.debug(f"Navigating to: {auth_url[:100]}...")
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
current_url = page.url
logger.debug(f"Current URL after navigation: {current_url}")
# Handle login if needed
if "/login" in current_url or "/index.php/login" in current_url:
logger.info("Login page detected, filling credentials...")
await page.wait_for_selector('input[name="user"]', timeout=10000)
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_load_state("networkidle", timeout=60000)
current_url = page.url
logger.info(f"After login: {current_url}")
# Wait a bit for page to fully render after login
await asyncio.sleep(2)
current_url = page.url
logger.info(f"After waiting, current URL: {current_url}")
# Check page content for debugging
page_content = await page.content()
has_consent_div = "#oidc-consent" in page_content
logger.info(f"Page has #oidc-consent div: {has_consent_div}")
# Handle consent screen using the helper from conftest
try:
consent_handled = await _handle_oauth_consent_screen(page, username)
logger.info(f"Consent screen handled: {consent_handled}")
except Exception as e:
logger.warning(f"Error handling consent screen: {e}")
# Take screenshot for debugging
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
logger.error("Consent error screenshot saved")
raise
# Wait for callback server to receive auth code
logger.info("Waiting for callback server to receive auth code...")
timeout_seconds = 30
start_time = time.time()
while state not in auth_states:
if time.time() - start_time > timeout_seconds:
screenshot_path = (
f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
)
await page.screenshot(path=screenshot_path)
logger.error(f"Timeout! Screenshot saved to {screenshot_path}")
logger.error(f"Current URL: {page.url}")
raise TimeoutError(
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
)
await asyncio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
finally:
await context.close()
# Exchange code for token
logger.debug("Exchanging authorization code for access token...")
async with httpx.AsyncClient(timeout=30.0) as http_client:
token_response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access_token in response: {token_data}")
logger.info("Successfully obtained access token")
return access_token
@pytest.mark.integration
async def test_client_cannot_introspect_other_clients_tokens(
playwright_oauth_token: str,
shared_oauth_client_credentials: tuple,
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
):
"""
Test that one client cannot introspect tokens owned by another client.
This test uses a pre-authorized shared OAuth client (with existing token)
and verifies that a different client cannot introspect that token.
Expected: introspection returns {active: false} to not reveal token existence.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
# Use the shared OAuth client's token (pre-authorized, working)
access_token = playwright_oauth_token
shared_client_id, shared_client_secret, _, _, _ = shared_oauth_client_credentials
# Get a different client to try to introspect
different_client_id, different_client_secret = test_oauth_clients["clientB"]
logger.info(
f"Testing introspection with shared client token: {access_token[:16]}..."
)
logger.info(f"Shared client ID: {shared_client_id[:16]}...")
logger.info(f"Different client ID: {different_client_id[:16]}...")
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: The owning client (shared client) can introspect its own token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(shared_client_id, shared_client_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Owner client introspection response: {data}")
assert data.get("active") is True, (
"Owner client should be able to introspect its own token"
)
# Test 2: A different client CANNOT introspect the shared client's token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(different_client_id, different_client_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Different client introspection response: {data}")
assert data.get("active") is False, (
"Different client should NOT be able to introspect another client's token"
)
@pytest.mark.integration
async def test_introspection_with_resource_parameter(
browser,
oauth_callback_server,
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
nextcloud_host: str,
):
"""
Test that the resource server (specified via 'resource' parameter) can introspect tokens.
This test verifies that when a token is issued with resource=clientB,
clientB can introspect it even though it's owned by clientA.
This requires obtaining a token with the 'resource' parameter set via authorization code grant.
Uses playwright automation to obtain real tokens.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
client_a_id, client_a_secret = test_oauth_clients["clientA"]
client_b_id, client_b_secret = test_oauth_clients["clientB"]
client_c_id, client_c_secret = test_oauth_clients["clientC"]
token_endpoint = oidc_endpoints["token_endpoint"]
authorization_endpoint = oidc_endpoints.get("authorization_endpoint")
if not authorization_endpoint:
pytest.skip("Authorization endpoint not available")
# Obtain a token for client A with resource parameter set to client B
try:
access_token = await _obtain_token_for_client(
browser=browser,
oauth_callback_server=oauth_callback_server,
client_id=client_a_id,
client_secret=client_a_secret,
token_endpoint=token_endpoint,
authorization_endpoint=authorization_endpoint,
scope="openid profile email",
resource=client_b_id, # Set client B as the resource server
)
except Exception as e:
logger.error(f"Failed to obtain token with resource parameter: {e}")
pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
logger.info(
f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..."
)
# Test introspection
async with httpx.AsyncClient(timeout=10.0) as client:
# Test 1: Client A (owner) can introspect its own token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_a_id, client_a_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client A (owner) introspection response: {data}")
assert data.get("active") is True, (
"Client A (owner) should be able to introspect its own token"
)
# Test 2: Client B (resource server) can introspect the token
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_b_id, client_b_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client B (resource server) introspection response: {data}")
assert data.get("active") is True, (
"Client B (resource server) should be able to introspect token intended for it"
)
# Verify the resource field in the response matches client B
logger.info(f"Full introspection response from Client B: {data}")
# Test 3: Client C CANNOT introspect the token (not owner, not resource server)
response = await client.post(
introspection_endpoint,
data={"token": access_token},
auth=(client_c_id, client_c_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Client C (third party) introspection response: {data}")
assert data.get("active") is False, (
"Client C should NOT be able to introspect token (not owner or resource server)"
)
@pytest.mark.integration
async def test_introspection_returns_inactive_for_invalid_token(
test_oauth_clients: dict[str, tuple[str, str]],
oidc_endpoints: dict[str, str],
):
"""
Test that introspection returns {active: false} for invalid/unknown tokens.
This is important for security - we shouldn't reveal whether a token exists or not.
"""
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
if not introspection_endpoint:
pytest.skip("Introspection endpoint not available")
client_a_id, client_a_secret = test_oauth_clients["clientA"]
async with httpx.AsyncClient(timeout=10.0) as client:
# Test with a fake token
response = await client.post(
introspection_endpoint,
data={"token": "completely_fake_token_12345"},
auth=(client_a_id, client_a_secret),
)
assert response.status_code == 200
data = response.json()
logger.info(f"Introspection response for fake token: {data}")
assert data.get("active") is False, (
"Should return active=false for invalid token"
)
# Should NOT return any other information
assert len(data) == 1, "Should only return 'active' field for invalid token"
if __name__ == "__main__":
# Run with: uv run pytest tests/server/test_introspection_authorization.py -v -s
pytest.main([__file__, "-v", "-s", "-m", "integration"])
+208
View File
@@ -0,0 +1,208 @@
"""
Test JWT token structure and scope support.
This test obtains a JWT token via OAuth and examines its structure.
"""
import base64
import json
import pytest
def decode_jwt_without_verification(token: str) -> dict:
"""
Decode JWT token without signature verification (for inspection only).
Returns:
Dict with header and payload
"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
# Decode header
header = json.loads(
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
)
# Decode payload
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
)
return {
"header": header,
"payload": payload,
}
@pytest.mark.integration
async def test_jwt_token_structure_with_custom_client():
"""
Test that we can create a JWT-enabled OAuth client and examine the token structure.
This test manually configures a JWT client and obtains a token.
"""
import os
import httpx
# This test requires manual setup of a JWT client
# Skip if not configured
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
if not client_id:
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Fetch discovery
async with httpx.AsyncClient() as client:
discovery_response = await client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
discovery_response.raise_for_status()
discovery = discovery_response.json()
_token_endpoint = discovery["token_endpoint"]
# For this test, we'll use client credentials grant if supported
# Otherwise, skip this test
pytest.skip(
"JWT token test requires OAuth flow - use manual testing script instead"
)
@pytest.mark.integration
async def test_opaque_token_vs_jwt_comparison():
"""
Compare opaque tokens vs JWT tokens to understand the differences.
This is a documentation test that explains the findings.
"""
# This test documents our findings about JWT vs opaque tokens
# Based on manual testing with the test script
findings = {
"oidc_app_capabilities": {
"supports_jwt_tokens": True,
"supports_opaque_tokens": True,
"configuration_method": "per-client via token_type field",
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
},
"dynamic_registration": {
"sets_allowed_scopes": False,
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
},
"jwt_token_structure": {
"header": {
"typ": "at+JWT", # RFC 9068 access token type
"alg": "RS256", # Signature algorithm
},
"payload_claims": {
"iss": "issuer URL",
"sub": "user ID",
"aud": "client ID",
"exp": "expiration timestamp",
"iat": "issued at timestamp",
"scope": "space-separated scope string (THIS IS THE KEY!)",
"client_id": "client identifier",
"jti": "JWT ID",
# Optional based on scopes:
"roles": "if roles scope present",
"groups": "if groups scope present",
"email": "if email scope present",
"name": "if profile scope present",
},
"scope_claim": {
"format": "space-separated string",
"example": "openid profile email nc:read nc:write",
"extraction": "payload['scope'].split()",
},
},
"scope_validation": {
"oidc_app": {
"validates": True,
"method": "Intersects requested scopes with allowed_scopes per client",
"location": "LoginRedirectorController.php:251-267",
},
"user_oidc_app": {
"validates_scopes": False,
"validates": ["token expiration", "issuer", "audience (optional)"],
"limitation": "Does NOT extract or validate scopes from JWT",
},
},
"token_size": {
"opaque": "72 characters",
"jwt": "~800-1200 characters (depends on claims)",
"overhead": "JWT is 10-15x larger than opaque tokens",
},
"recommendation": {
"for_mcp_server": "Use JWT tokens with self-validation",
"reasoning": [
"Can extract scopes directly from token payload",
"No additional API call needed",
"Standard approach (RFC 9068)",
"Works with existing oidc app",
],
"alternative": "Implement introspection endpoint in oidc app (future work)",
},
}
# Print findings for documentation
print("\n" + "=" * 80)
print("JWT Token vs Opaque Token Findings")
print("=" * 80)
print(json.dumps(findings, indent=2))
print("=" * 80 + "\n")
# This test always passes - it's for documentation
assert True, "Findings documented"
@pytest.mark.integration
async def test_scope_presence_in_jwt():
"""
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
NOTE: This test documents the expected behavior based on manual testing.
Actual implementation will be tested in integration tests after JWT validation is implemented.
"""
expected_behavior = {
"client_configuration": {
"allowed_scopes": "openid profile email nc:read nc:write",
"token_type": "jwt",
},
"authorization_request": {
"scope": "openid profile email nc:read nc:write",
},
"token_response": {
"access_token": "JWT with scope claim",
},
"jwt_payload": {
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
},
"scope_filtering": {
"description": "oidc app filters requested scopes against allowed_scopes",
"example": {
"requested": "openid profile nc:read nc:write nc:admin",
"allowed": "openid profile email nc:read nc:write",
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
},
},
}
print("\n" + "=" * 80)
print("Expected JWT Scope Behavior")
print("=" * 80)
print(json.dumps(expected_behavior, indent=2))
print("=" * 80 + "\n")
assert True, "Expected behavior documented"
if __name__ == "__main__":
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
pytest.main([__file__, "-v", "-s"])
+11
View File
@@ -40,6 +40,12 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_webdav_write_file",
"nc_webdav_create_directory",
"nc_webdav_delete_resource",
"nc_webdav_move_resource",
"nc_webdav_copy_resource",
"nc_webdav_search_files",
"nc_webdav_find_by_name",
"nc_webdav_find_by_type",
"nc_webdav_list_favorites",
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
@@ -51,6 +57,11 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_calendar_search_todos",
"deck_create_board",
"nc_cookbook_import_recipe",
"nc_cookbook_list_recipes",
+1
View File
@@ -1,5 +1,6 @@
import json
import logging
import pytest
logger = logging.getLogger(__name__)
+246
View File
@@ -0,0 +1,246 @@
"""Integration tests for JWT OAuth authentication.
These tests verify:
1. JWT token authentication works correctly
2. JWT token verification via JWKS
3. Scope information is properly extracted from JWT claims
4. Dynamic tool filtering works with JWT tokens
5. All MCP operations work with JWT authentication
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client):
"""Test connection to JWT OAuth-enabled MCP server."""
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available")
async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client):
"""Test that JWT token authentication works."""
# Execute a simple read operation
result = await nc_mcp_oauth_jwt_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)
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info(
f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes."
)
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
"""Test that list_tools works with JWT authentication."""
result = await nc_mcp_oauth_jwt_client.list_tools()
# Verify we have tools
assert len(result.tools) > 0
# Verify some expected tools exist
tool_names = [tool.name for tool in result.tools]
assert "nc_notes_get_note" in tool_names
assert "nc_notes_create_note" in tool_names
assert "nc_calendar_list_calendars" in tool_names
assert "nc_webdav_list_directory" in tool_names
logger.info(f"JWT server provides {len(result.tools)} tools")
async def test_jwt_read_operation(nc_mcp_oauth_jwt_client):
"""Test read operation with JWT authentication."""
# List calendars (read operation)
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
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)
assert "calendars" in response_data
assert isinstance(response_data["calendars"], list)
logger.info(
f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars."
)
async def test_jwt_write_operation(nc_mcp_oauth_jwt_client):
"""Test write operation with JWT authentication."""
import uuid
# Create a note (write operation)
note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}"
note_content = "This note was created during JWT authentication testing"
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": note_content,
"category": "Testing",
},
)
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)
# Verify note was created
assert "id" in response_data
assert response_data["title"] == note_title
note_id = response_data["id"]
logger.info(f"Successfully created note {note_id} with JWT authentication")
# Clean up: Delete the note
delete_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}"
logger.info(f"Cleaned up test note {note_id}")
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
"""Test multiple operations with same JWT token to verify token persistence."""
# First operation: Search notes
result1 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result1.isError is False
# Second operation: List calendars
result2 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result2.isError is False
# Third operation: List directory
result3 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert result3.isError is False
logger.info("Successfully executed multiple operations with JWT token")
async def test_jwt_vs_opaque_token_compatibility(
nc_mcp_oauth_client, nc_mcp_oauth_jwt_client
):
"""Verify that both opaque and JWT tokens provide same functionality."""
# Execute same operation on both servers
opaque_result = await nc_mcp_oauth_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
jwt_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
# Both should succeed
assert opaque_result.isError is False
assert jwt_result.isError is False
# Both should have results
opaque_data = json.loads(opaque_result.content[0].text)
jwt_data = json.loads(jwt_result.content[0].text)
assert "results" in opaque_data
assert "results" in jwt_data
# Results should be the same (same user, same notes)
assert len(opaque_data["results"]) == len(jwt_data["results"])
logger.info(
"Verified opaque and JWT tokens provide identical functionality: "
f"{len(opaque_data['results'])} notes accessible from both servers"
)
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
"""Test error handling with JWT authentication."""
# Try to get a non-existent note
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_get_note", arguments={"note_id": 999999}
)
# Should get an error (note doesn't exist)
assert result.isError is True
logger.info("JWT server correctly handles errors for invalid operations")
async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client):
"""Test that JWT server properly enforces scopes."""
# This test assumes the JWT token has both nc:read and nc:write scopes
# Both read and write operations should succeed
# Read operation
read_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert read_result.isError is False
# Write operation
import uuid
note_title = f"Scope Test {uuid.uuid4().hex[:8]}"
write_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": "Testing scope enforcement",
"category": "Testing",
},
)
assert write_result.isError is False
# Clean up
note_id = json.loads(write_result.content[0].text)["id"]
await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
logger.info("JWT server properly allows operations based on token scopes")
async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client):
"""Test that verifies the automated JWT client creation worked correctly.
This test confirms that:
1. JWT client was auto-created during container initialization
2. MCP server loaded credentials from auto-generated file
3. JWT authentication flow works end-to-end
4. Server uses JWT tokens (not opaque tokens)
"""
# If we can connect and execute tools, the automation worked
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
# Execute a tool to verify full OAuth flow
tool_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert tool_result.isError is False
logger.info(
"✅ JWT client automation successful! "
"Auto-generated credentials working correctly."
)
+4 -4
View File
@@ -46,7 +46,7 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_view_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -119,7 +119,7 @@ async def test_deck_board_view_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_edit_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -214,7 +214,7 @@ async def test_deck_board_edit_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_board_manage_permissions(
nc_client, alice_mcp_client, charlie_mcp_client
):
@@ -289,7 +289,7 @@ async def test_deck_board_manage_permissions(
await nc_client.deck.delete_board(board_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own boards when not shared.
+4 -4
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_share_read_permissions(
alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -104,7 +104,7 @@ async def test_file_share_read_permissions(
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_share_write_permissions(
alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -210,7 +210,7 @@ async def test_file_share_write_permissions(
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that file listing respects share permissions.
@@ -326,7 +326,7 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
"""
Test that folder sharing works correctly.
+4 -4
View File
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_notes_share_read_permissions(
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
):
@@ -82,7 +82,7 @@ async def test_notes_share_read_permissions(
await nc_client.notes.delete_note(note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_notes_share_write_permissions(
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
):
@@ -149,7 +149,7 @@ async def test_notes_share_write_permissions(
await nc_client.notes.delete_note(note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
"""
Test that users can only see their own notes when not shared.
@@ -222,7 +222,7 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
await nc_client.notes.delete_note(bob_note_id)
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_oauth_mcp_clients_initialized(
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
):
+539
View File
@@ -0,0 +1,539 @@
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
These tests verify:
1. Dynamic tool filtering based on user's token scopes
2. Scope enforcement (403 responses for insufficient scopes)
3. Protected Resource Metadata (PRM) endpoint
4. WWW-Authenticate challenge headers
5. BasicAuth bypass (all tools visible)
"""
import pytest
@pytest.mark.integration
async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
async with httpx.AsyncClient() as client:
response = await client.get(
"http://localhost:8001/.well-known/oauth-protected-resource/mcp"
)
assert response.status_code == 200
prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8001/mcp"
assert "nc:read" in prm_data["scopes_supported"]
assert "nc:write" in prm_data["scopes_supported"]
assert "http://localhost:8080" in prm_data["authorization_servers"]
assert "header" in prm_data["bearer_methods_supported"]
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
@pytest.mark.integration
async def test_basicauth_shows_all_tools(nc_mcp_client):
"""Test that BasicAuth mode shows all tools (no filtering)."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# List all tools
tools_response = await nc_mcp_client.list_tools()
# BasicAuth should see all tools
tool_names = [tool.name for tool in tools_response.tools]
# Should see both read and write tools
assert "nc_notes_get_note" in tool_names # read tool
assert "nc_notes_create_note" in tool_names # write tool
assert "nc_calendar_list_calendars" in tool_names # read tool
assert "nc_calendar_create_event" in tool_names # write tool
# Should have all 90+ tools
assert len(tool_names) >= 90
@pytest.mark.integration
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
"""Test that a token with only nc:read scope filters out write tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:read" scope
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Read-only token sees {len(tool_names)} tools")
# Verify read tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
# Verify write tools are NOT present
write_tools_should_be_filtered = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in write_tools_should_be_filtered:
assert tool not in tool_names, (
f"Write tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, "
f"write tools hidden"
)
@pytest.mark.integration
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
"""Test that a token with only nc:write scope filters out read tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:write" scope
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Write-only token sees {len(tool_names)} tools")
# Verify write tools are present
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
# Verify read tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in read_tools_should_be_filtered:
assert tool not in tool_names, (
f"Read tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, "
f"read tools hidden"
)
@pytest.mark.integration
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
"""Test that a token with both nc:read and nc:write scopes can see all tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has both "nc:read" and "nc:write" scopes
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Full access token sees {len(tool_names)} tools")
# Verify both read and write tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_webdav_read_file",
]
expected_write_tools = [
"nc_notes_create_note",
"nc_calendar_create_event",
"nc_webdav_write_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all 90+ tools (both read and write)
assert len(tool_names) >= 90
logger.info(
f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)"
)
@pytest.mark.integration
async def test_scope_helper_functions():
"""Test the scope authorization helper functions."""
from nextcloud_mcp_server.auth import get_required_scopes, has_required_scopes
# Create a mock function with scope requirements
async def mock_read_tool():
pass
async def mock_write_tool():
pass
async def mock_no_scope_tool():
pass
# Add scope metadata
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
# Test get_required_scopes
assert get_required_scopes(mock_read_tool) == ["nc:read"]
assert get_required_scopes(mock_write_tool) == ["nc:write"]
assert get_required_scopes(mock_no_scope_tool) == []
# Test has_required_scopes
read_only_scopes = {"nc:read"}
full_scopes = {"nc:read", "nc:write"}
no_scopes = set()
# User with only read scope
assert has_required_scopes(mock_read_tool, read_only_scopes) is True
assert has_required_scopes(mock_write_tool, read_only_scopes) is False
assert has_required_scopes(mock_no_scope_tool, read_only_scopes) is True
# User with full scopes
assert has_required_scopes(mock_read_tool, full_scopes) is True
assert has_required_scopes(mock_write_tool, full_scopes) is True
assert has_required_scopes(mock_no_scope_tool, full_scopes) is True
# User with no scopes
assert has_required_scopes(mock_read_tool, no_scopes) is False
assert has_required_scopes(mock_write_tool, no_scopes) is False
assert has_required_scopes(mock_no_scope_tool, no_scopes) is True
@pytest.mark.integration
async def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator properly stores metadata."""
from nextcloud_mcp_server.auth import require_scopes
@require_scopes("nc:read", "nc:write")
async def test_function():
pass
# Check that metadata was stored
assert hasattr(test_function, "_required_scopes")
assert test_function._required_scopes == ["nc:read", "nc:write"]
@pytest.mark.integration
async def test_tools_have_scope_decorators(nc_mcp_client):
"""Test that MCP tools have scope requirements defined."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# We can at least verify that some expected tools exist
tools_response = await nc_mcp_client.list_tools()
tool_names = [tool.name for tool in tools_response.tools]
# Verify expected read tools exist
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_contacts_list_contacts",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify expected write tools exist
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_contacts_create_contact",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
@pytest.mark.integration
async def test_scope_classification():
"""Test that our scope classification correctly identifies read vs write operations."""
from scripts.add_scope_decorators_simple import classify_function
# Test read operations
assert classify_function("nc_notes_get_note") == "nc:read"
assert classify_function("nc_notes_search_notes") == "nc:read"
assert classify_function("nc_calendar_list_events") == "nc:read"
assert classify_function("nc_webdav_read_file") == "nc:read"
assert classify_function("nc_calendar_find_availability") == "nc:read"
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
# Test write operations
assert classify_function("nc_notes_create_note") == "nc:write"
assert classify_function("nc_notes_update_note") == "nc:write"
assert classify_function("nc_notes_delete_note") == "nc:write"
assert classify_function("nc_notes_append_content") == "nc:write"
assert classify_function("nc_calendar_create_event") == "nc:write"
assert classify_function("nc_calendar_update_event") == "nc:write"
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
assert classify_function("nc_webdav_write_file") == "nc:write"
assert classify_function("nc_webdav_move_resource") == "nc:write"
assert classify_function("nc_contacts_create_contact") == "nc:write"
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
assert classify_function("nc_tables_insert_row") == "nc:write"
assert classify_function("deck_archive_card") == "nc:write"
assert classify_function("deck_assign_label_to_card") == "nc:write"
@pytest.mark.integration
async def test_all_tools_classified():
"""Verify that all tools can be properly classified as read or write."""
from scripts.add_scope_decorators_simple import classify_function
# List of all tool names (extracted from our implementation)
all_tools = [
# Calendar tools
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
"nc_calendar_get_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_calendar_create_meeting",
"nc_calendar_get_upcoming_events",
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_calendar_search_todos",
# Notes tools
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_append_content",
"nc_notes_delete_note",
"nc_notes_get_attachment",
# Add more as needed...
]
unclassified = []
for tool_name in all_tools:
scope = classify_function(tool_name)
if scope is None:
unclassified.append(tool_name)
# All tools should be classifiable
assert len(unclassified) == 0, f"Unclassified tools: {unclassified}"
@pytest.mark.integration
async def test_scope_metadata_coverage(nc_mcp_client):
"""Test that all tools have scope metadata defined (no undecorated tools)."""
# This test would require access to the actual tool functions to check metadata
# For now, we verify that the expected number of tools exists
# Note: Don't use 'async with' for session-scoped fixtures
tools_response = await nc_mcp_client.list_tools()
# We applied decorators to 90 tools
# In BasicAuth mode, all should be visible
assert len(tools_response.tools) >= 90
@pytest.mark.integration
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
nc_mcp_oauth_client_no_custom_scopes,
):
"""
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
This tests the security behavior when a user declines to grant custom scopes during consent.
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
All tools require at least one custom scope, so they should all be filtered out.
"""
import logging
logger = logging.getLogger(__name__)
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
)
# All tools require nc:read or nc:write, so should be filtered out
assert len(tool_names) == 0, (
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
)
logger.info(
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
"""
Test JWT with only nc:read scope consented.
Simulates user granting only read permission during OAuth consent.
Expected: Should see read tools but not write tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
# Verify read tools are present
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify write tools are filtered out
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
"""
Test JWT with only nc:write scope consented.
Simulates user granting only write permission during OAuth consent.
Expected: Should see write tools but not read-only tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
# Verify write tools are present
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Verify read-only tools are filtered out
read_only_tools = ["nc_notes_get_note", "nc_notes_search_notes"]
for tool in read_only_tools:
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
"""
Test JWT with both nc:read and nc:write scopes consented.
Simulates user granting both permissions during OAuth consent.
Expected: Should see all 90+ tools (both read and write).
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
# Verify both read and write tools are present
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
write_tools = ["nc_notes_create_note", "nc_webdav_write_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all tools
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
logger.info(
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+7 -6
View File
@@ -1,8 +1,9 @@
import pytest
from nextcloud_mcp_server.client import NextcloudClient
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
"""Test creating a user and verifying deletion (cleanup by fixture)."""
user_config = test_user
@@ -28,7 +29,7 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_update_user_field(nc_client: NextcloudClient, test_user):
"""Test updating user fields."""
user_config = test_user
@@ -43,7 +44,7 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
"""Test adding and removing users from groups."""
user_config, groupid = test_user_in_group
@@ -60,7 +61,7 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
# Fixtures will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
"""Test promoting and demoting subadmins."""
user_config = test_user
@@ -81,7 +82,7 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
# Fixtures will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
"""Test disabling and enabling users."""
user_config = test_user
@@ -101,7 +102,7 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
# Fixture will handle cleanup
@pytest.mark.asyncio
@pytest.mark.anyio
async def test_get_editable_user_fields(nc_client: NextcloudClient):
editable_fields = await nc_client.users.get_editable_user_fields()
assert "displayname" in editable_fields
+322
View File
@@ -0,0 +1,322 @@
"""Integration tests for WebDAV search MCP tools."""
import json
import logging
import uuid
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.integration
def normalize_search_response(data):
"""Extract results list from SearchFilesResponse.
The response is a SearchFilesResponse with a 'results' field containing the list of files.
"""
if isinstance(data, dict) and "results" in data:
return data["results"]
else:
# Fallback for unexpected format
return []
@pytest.fixture
async def search_test_files(nc_client: NextcloudClient):
"""Create test files for WebDAV search testing via MCP."""
test_dir = f"mcp_webdav_search_{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}/search_test1.txt", b"Sample document", "text/plain"),
(f"{test_dir}/search_test2.txt", b"Another document", "text/plain"),
(f"{test_dir}/search_report.txt", b"Report content", "text/plain"),
# Markdown files
(f"{test_dir}/search_readme.md", b"# README", "text/markdown"),
(f"{test_dir}/search_notes.md", b"# Notes", "text/markdown"),
# Images (simulated)
(f"{test_dir}/search_image.jpg", b"\xff\xd8\xff fake jpg", "image/jpeg"),
(f"{test_dir}/search_photo.png", b"\x89PNG fake png", "image/png"),
# PDF (simulated)
(f"{test_dir}/search_presentation.pdf", b"%PDF-1.4", "application/pdf"),
]
# 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 {len(test_files)} test files in {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_dir}: {e}")
async def test_nc_webdav_find_by_name(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_find_by_name MCP tool."""
# Find all .txt files in the test directory
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_name",
arguments={
"pattern": "search_%.txt",
"scope": search_test_files,
},
)
# Parse the result
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} files matching 'search_%.txt'")
# Should find at least 3 .txt files
assert len(files) >= 3, f"Expected at least 3 .txt files, got {len(files)}"
# Verify all results end with .txt
for file in files:
name = file.get("name", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
assert name.startswith("search_"), (
f"Expected name to start with 'search_', got {name}"
)
async def test_nc_webdav_find_by_name_with_limit(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_find_by_name with limit parameter."""
# Find files with limit
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_name",
arguments={
"pattern": "search_%.txt",
"scope": search_test_files,
"limit": 2,
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} files with limit=2")
# Should return at most 2 results
assert len(files) <= 2, f"Expected at most 2 files, got {len(files)}"
assert len(files) > 0, "Expected at least 1 file"
async def test_nc_webdav_find_by_type_images(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_find_by_type for images."""
# Find all images
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_type",
arguments={
"mime_type": "image/%",
"scope": search_test_files,
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} image files")
# Should find at least 2 image files (jpg and png)
assert len(files) >= 2, f"Expected at least 2 image files, got {len(files)}"
# Verify all results are images
for file in files:
content_type = file.get("content_type", "")
assert content_type.startswith("image/"), (
f"Expected image/* type, got {content_type}"
)
async def test_nc_webdav_find_by_type_specific(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_find_by_type for specific MIME type."""
# Find PDF files
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_type",
arguments={
"mime_type": "application/pdf",
"scope": search_test_files,
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} PDF files")
# Should find at least 1 PDF
assert len(files) >= 1, f"Expected at least 1 PDF file, got {len(files)}"
# Verify result is PDF
for file in files:
content_type = file.get("content_type", "")
assert content_type == "application/pdf", (
f"Expected application/pdf, got {content_type}"
)
async def test_nc_webdav_search_files_basic(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_search_files with basic filters."""
# Search for markdown files
result = await nc_mcp_client.call_tool(
"nc_webdav_search_files",
arguments={
"scope": search_test_files,
"name_pattern": "%.md",
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} markdown files")
# Should find at least 2 .md files
assert len(files) >= 2, f"Expected at least 2 .md files, got {len(files)}"
# Verify all results are .md files
for file in files:
name = file.get("name", "")
assert name.endswith(".md"), f"Expected .md file, got {name}"
async def test_nc_webdav_search_files_combined(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_search_files with combined filters."""
# Search for text files with specific name pattern
result = await nc_mcp_client.call_tool(
"nc_webdav_search_files",
arguments={
"scope": search_test_files,
"name_pattern": "search_test%.txt",
"mime_type": "text/plain",
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} files matching combined filters")
# Should find search_test1.txt and search_test2.txt
assert len(files) >= 2, f"Expected at least 2 files, got {len(files)}"
# Verify all results match both conditions
for file in files:
name = file.get("name", "")
content_type = file.get("content_type", "")
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
assert name.startswith("search_test"), (
f"Expected 'search_test' prefix, got {name}"
)
assert content_type == "text/plain", f"Expected text/plain, got {content_type}"
async def test_nc_webdav_search_files_with_limit(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test nc_webdav_search_files with result limit."""
# Search with limit
result = await nc_mcp_client.call_tool(
"nc_webdav_search_files",
arguments={
"scope": search_test_files,
"name_pattern": "search_%",
"limit": 3,
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
logger.info(f"Found {len(files)} files with limit=3")
# Should return at most 3 results
assert len(files) <= 3, f"Expected at most 3 files, got {len(files)}"
assert len(files) > 0, "Expected at least 1 file"
async def test_nc_webdav_search_no_results(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test search that returns no results."""
# Search for non-existent pattern
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_name",
arguments={
"pattern": "nonexistent_xyz123.txt",
"scope": search_test_files,
},
)
# Handle case where empty results might return empty content
if result.content and len(result.content) > 0:
content = result.content[0].text
files = normalize_search_response(json.loads(content))
else:
files = []
logger.info("Search correctly returned no results")
# Should return empty array
assert len(files) == 0, f"Expected no results, got {len(files)}"
async def test_search_result_properties(
nc_mcp_client: ClientSession, search_test_files: str
):
"""Test that search results include expected properties."""
# Search for a specific file
result = await nc_mcp_client.call_tool(
"nc_webdav_find_by_name",
arguments={
"pattern": "search_readme.md",
"scope": search_test_files,
},
)
content = result.content[0].text
files = normalize_search_response(json.loads(content))
assert len(files) >= 1, "Should find at least one file"
file = files[0]
# Check for expected properties
assert "name" in file, "Should include name property"
assert "path" in file, "Should include path property"
assert "is_directory" in file, "Should include is_directory property"
assert file["is_directory"] is False, "File should not be a directory"
# Check for extended properties from search
extended_props = ["file_id", "etag", "size", "content_type", "last_modified"]
present_props = [prop for prop in extended_props if prop in file]
logger.info(f"Search result properties: {list(file.keys())}")
assert len(present_props) > 0, f"Should have at least one of {extended_props}"
+285
View File
@@ -0,0 +1,285 @@
"""Tests for CLI options using Click's testing utilities."""
import os
import pytest
from click.testing import CliRunner
from nextcloud_mcp_server.app import run
@pytest.fixture
def runner():
"""Create a Click CLI runner."""
return CliRunner()
@pytest.fixture
def clean_env(monkeypatch):
"""Clean environment variables before each test."""
env_vars = [
"NEXTCLOUD_HOST",
"NEXTCLOUD_USERNAME",
"NEXTCLOUD_PASSWORD",
"NEXTCLOUD_OIDC_CLIENT_ID",
"NEXTCLOUD_OIDC_CLIENT_SECRET",
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
"NEXTCLOUD_OIDC_SCOPES",
"NEXTCLOUD_OIDC_TOKEN_TYPE",
"NEXTCLOUD_MCP_SERVER_URL",
"NEXTCLOUD_PUBLIC_ISSUER_URL",
]
for var in env_vars:
monkeypatch.delenv(var, raising=False)
def test_help_message_displays_all_options(runner):
"""Test that help message includes all new CLI options."""
result = runner.invoke(run, ["--help"])
assert result.exit_code == 0
# Check for new options
assert "--nextcloud-host" in result.output
assert "--nextcloud-username" in result.output
assert "--nextcloud-password" in result.output
assert "--oauth-scopes" in result.output
assert "--oauth-token-type" in result.output
assert "--public-issuer-url" in result.output
# Check for existing options
assert "--oauth-client-id" in result.output
assert "--oauth-client-secret" in result.output
assert "--mcp-server-url" in result.output
def test_token_type_accepts_valid_values(runner, clean_env):
"""Test that --oauth-token-type accepts bearer and jwt (case insensitive)."""
# Test lowercase bearer
result = runner.invoke(run, ["--oauth-token-type", "bearer", "--help"])
assert result.exit_code == 0
# Test lowercase jwt
result = runner.invoke(run, ["--oauth-token-type", "jwt", "--help"])
assert result.exit_code == 0
# Test uppercase (should work with case_sensitive=False)
result = runner.invoke(run, ["--oauth-token-type", "Bearer", "--help"])
assert result.exit_code == 0
result = runner.invoke(run, ["--oauth-token-type", "JWT", "--help"])
assert result.exit_code == 0
def test_token_type_rejects_invalid_values(runner, clean_env):
"""Test that --oauth-token-type rejects invalid values."""
result = runner.invoke(run, ["--oauth-token-type", "invalid"])
assert result.exit_code != 0
assert "Invalid value" in result.output
def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch):
"""Test that CLI options set environment variables correctly."""
# We need to mock the actual server startup to avoid connection errors
# Store the env vars that get set
captured_env = {}
def mock_get_app(*args, **kwargs):
# Capture environment variables after they're set by CLI
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
}
)
# Raise an exception to stop execution before uvicorn.run
raise SystemExit(0)
# Patch get_app to capture env vars
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://test.example.com",
"--nextcloud-username",
"testuser",
"--nextcloud-password",
"testpass",
"--oauth-scopes",
"openid nc:read",
"--oauth-token-type",
"jwt",
"--public-issuer-url",
"https://public.example.com",
"--mcp-server-url",
"http://test:8000",
],
)
# Verify environment variables were set
assert captured_env["NEXTCLOUD_HOST"] == "https://test.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "testuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "testpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:read"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public.example.com"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://test:8000"
def test_cli_options_override_environment_variables(runner, monkeypatch):
"""Test that CLI options override environment variables."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "bearer")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Provide CLI options that should override env vars
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://from-cli.example.com",
"--nextcloud-username",
"cliuser",
"--oauth-scopes",
"openid nc:write",
"--oauth-token-type",
"jwt",
],
)
# Verify CLI options overrode env vars
assert captured_env["NEXTCLOUD_HOST"] == "https://from-cli.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "cliuser"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:write"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch):
"""Test that environment variables are used when CLI options not provided."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "envpass")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid email")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "jwt")
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://public-env.example.com")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Don't provide any CLI options - should use env vars
_ = runner.invoke(run, [])
# Verify env vars were used
assert captured_env["NEXTCLOUD_HOST"] == "https://from-env.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "envuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "envpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid email"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert (
captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public-env.example.com"
)
def test_default_values(runner, clean_env, monkeypatch):
"""Test that default values are used when neither CLI nor env vars provided."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Don't provide CLI options or env vars - should use defaults
_ = runner.invoke(run, [])
# Verify default values
assert (
captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid profile email nc:read nc:write"
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
assert (
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
)
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
"""Test that token type is normalized correctly regardless of input case."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] = os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Test uppercase JWT
runner.invoke(run, ["--oauth-token-type", "JWT"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["JWT", "jwt"]
# Test mixed case Bearer
captured_env.clear()
runner.invoke(run, ["--oauth-token-type", "Bearer"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["Bearer", "bearer"]
Vendored Submodule
+1
Submodule third_party/oidc added at 0842fad479
-674
View File
@@ -1,674 +0,0 @@
=========================
Instruction set for users
=========================
Add a new user
--------------
Create a new user on the Nextcloud server. Authentication is done by sending a
basic HTTP authentication header.
**Syntax: ocs/v1.php/cloud/users**
* HTTP method: POST
* POST argument: userid - string, the required username for the new user
* POST argument: password - string, the password for the new user, leave empty to send welcome mail
* POST argument: displayName - string, the display name for the new user
* POST argument: email - string, the email for the new user, required if password empty
* POST argument: groups - array, the groups for the new user
* POST argument: subadmin - array, the groups in which the new user is subadmin
* POST argument: quota - string, quota for the new user
* POST argument: language - string, language for the new user
Status codes:
* 101 - invalid argument
* 102 - user already exists
* 103 - cannot create sub-admins for admin group
* 104 - group does not exist
* 105 - insufficient privileges for group
* 106 - no group specified (required for sub-admins)
* 107 - hint exceptions
* 108 - an email address is required, to send a password link to the user.
* 109 - sub-admin group does not exist
* 110 - required email address was not provided
* 111 - could not create non-existing user ID
Example
^^^^^^^
::
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true"
* Creates the user ``Frank`` with password ``frankspassword``
* optionally groups can be specified by one or more ``groups[]`` query parameters:
``URL -d groups[]="admin" -D groups[]="Team1"``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Search/get users
----------------
Retrieves a list of users from the Nextcloud server. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users**
* HTTP method: GET
* url arguments: search - string, optional search string
* url arguments: limit - int, optional limit value
* url arguments: offset - int, optional offset value
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true"
* Returns list of users matching the search string.
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<users>
<element>Frank</element>
</users>
</data>
</ocs>
Get data of a single user
-------------------------
Retrieves information about a single user. Authentication is done by sending a
Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: GET
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
* Returns information on the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<enabled>true</enabled>
<id>Frank</id>
<quota>0</quota>
<email>frank@example.org</email>
<displayname>Frank K.</displayname>
<display-name>Frank K.</display-name>
<phone>0123 / 456 789</phone>
<address>Foobar 12, 12345 Town</address>
<website>https://nextcloud.com</website>
<twitter>Nextcloud</twitter>
<groups>
<element>group1</element>
<element>group2</element>
</groups>
</data>
</ocs>
Edit data of a single user
--------------------------
Edits attributes related to a user. Users are able to edit email, displayname
and password; admins can also edit the quota value. Further restrictions may apply,
check the `List of editable data fields`_ endpoint. Authentication
is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: PUT
* PUT argument: key, the field to edit:
+ email
+ quota
+ displayname
+ display (**deprecated** use `displayname` instead)
+ phone
+ address
+ website
+ twitter
+ password
* PUT argument: value, the new value for the field
Status codes:
* 101 - invalid argument
* 107 - password policy (hint exception)
* 112 - Setting the password is not supported by the users backend
* 113 - editing field not allowed / field doesnt exist
Examples
^^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true"
* Updates the email address for the user ``Frank``
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true"
* Updates the quota for the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
.. _editable_field_list:
List of editable data fields
----------------------------
Edits attributes related to a user. Users are able to edit email, displayname
and password; admins can also edit the quota value. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/user/fields**
* HTTP method: GET
Status codes:
* 100 - successful
Examples
^^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true"
* Gets the list of fields
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message>OK</message>
</meta>
<data>
<element>displayname</element>
<element>email</element>
<element>phone</element>
<element>address</element>
<element>website</element>
<element>twitter</element>
</data>
</ocs>
Disable a user
--------------
Disables a user on the Nextcloud server so that the user cannot login anymore.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/disable**
* HTTP method: PUT
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true"
* Disables the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Enable a user
-------------
Enables a user on the Nextcloud server so that the user can login again.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/enable**
* HTTP method: PUT
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true"
* Enables the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Delete a user
-------------
Deletes a user from the Nextcloud server. Authentication is done by sending a
Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}**
* HTTP method: DELETE
Statuscodes:
* 100 - successful
* 101 - failure
Example
^^^^^^^
::
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
* Deletes the user ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Get user's groups
-----------------
Retrieves a list of groups the specified user is a member of. Authentication is
done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: GET
Status codes:
* 100 - successful
Example
^^^^^^^
::
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true"
* Retrieves a list of groups of which ``Frank`` is a member
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data>
<groups>
<element>admin</element>
<element>group1</element>
</groups>
</data>
</ocs>
Add user to group
-----------------
Adds the specified user to the specified group. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: POST
* POST argument: groupid, string - the group to add the user to
Status codes:
* 100 - successful
* 101 - no group specified
* 102 - group does not exist
* 103 - user does not exist
* 104 - insufficient privileges
* 105 - failed to add user to group
Example
^^^^^^^
::
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
* Adds the user ``Frank`` to the group ``newgroup``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Remove user from group
----------------------
Removes the specified user from the specified group. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
* HTTP method: DELETE
* DELETE argument: groupid, string - the group to remove the user from
Status codes:
* 100 - successful
* 101 - no group specified
* 102 - group does not exist
* 103 - user does not exist
* 104 - insufficient privileges
* 105 - failed to remove user from group
Example
^^^^^^^
::
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
* Removes the user ``Frank`` from the group ``newgroup``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Promote user to subadmin
------------------------
Makes a user the subadmin of a group. Authentication is done by sending a Basic
HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: POST
* POST argument: groupid, string - the group of which to make the user a
subadmin
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - group does not exist
* 103 - unknown failure
Example
^^^^^^^
::
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true"
* Makes the user ``Frank`` a subadmin of the ``group`` group
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Demote user from subadmin
-------------------------
Removes the subadmin rights for the user specified from the group specified.
Authentication is done by sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: DELETE
* DELETE argument: groupid, string - the group from which to remove the user's
subadmin rights
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - user is not a subadmin of the group / group does not exist
* 103 - unknown failure
Example
^^^^^^^
::
$ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true"
* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<statuscode>100</statuscode>
<status>ok</status>
</meta>
<data/>
</ocs>
Get user's subadmin groups
--------------------------
Returns the groups in which the user is a subadmin. Authentication is done by
sending a Basic HTTP Authorization header.
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
* HTTP method: GET
Status codes:
* 100 - successful
* 101 - user does not exist
* 102 - unknown failure
Example
^^^^^^^
::
$ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true"
* Returns the groups of which ``Frank`` is a subadmin
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data>
<element>testgroup</element>
</data>
</ocs>
Resend the welcome email
------------------------
The request to this endpoint triggers the welcome email for this user again.
**Syntax: ocs/v1.php/cloud/users/{userid}/welcome**
* HTTP method: POST
Status codes:
* 100 - successful
* 101 - email address not available
* 102 - sending email failed
Example
^^^^^^^
::
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true"
* Sends the welcome email to ``Frank``
XML output
^^^^^^^^^^
.. code-block:: xml
<?xml version="1.0"?>
<ocs>
<meta>
<status>ok</status>
<statuscode>100</statuscode>
<message/>
</meta>
<data/>
</ocs>
Generated
+680 -273
View File
File diff suppressed because it is too large Load Diff