Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87c6f077f3 | |||
| 38e12db46a | |||
| 1a7ce5b7a7 | |||
| 737780b417 | |||
| b4039e2e40 | |||
| 54e975198f | |||
| e9a16c43b5 | |||
| e48f5f3f30 | |||
| 3ebc468a09 | |||
| 1aecb099e6 | |||
| 2c35e07675 | |||
| 5cfdff0faf | |||
| eb7e15cac0 | |||
| 894723c525 | |||
| 8a3269f366 | |||
| c069d78f80 | |||
| e3436fecc0 | |||
| e3feb3eb2f | |||
| eedaa2e3f1 | |||
| d517fe09d8 | |||
| 08ebab9f48 | |||
| f4f9548681 | |||
| 27bb0a4b56 | |||
| 7f5828390c | |||
| 8ad1937347 | |||
| 0d29048155 | |||
| 499429706c | |||
| 2903094d67 | |||
| 7abfa19d15 | |||
| c109626601 | |||
| a5a4e809c4 | |||
| 4984496d81 | |||
| 0e79ba06a9 | |||
| 48744e8a6c | |||
| 63b898c0e3 | |||
| e8f1340133 | |||
| fde68dac55 | |||
| 460e2e190c | |||
| 989b6de3c0 | |||
| aa0b6dc5dd | |||
| 7ae78d3a39 | |||
| 54326f9c64 | |||
| 6ba87e7e05 | |||
| 45bbf97033 | |||
| 14a0f166fe | |||
| 71f09a47ca | |||
| 61bb8cc048 | |||
| ad9b9f25a1 | |||
| f4dd68735c | |||
| c75f0c0a17 | |||
| a143123acc | |||
| 1dc2ddfdb7 | |||
| 92e18825bc | |||
| d398a8c8e6 | |||
| 39dfa13895 | |||
| cb7a609ec2 | |||
| b8d241b596 | |||
| 5395f8d3d6 | |||
| 198d7495f0 | |||
| c2f6c6ce0d | |||
| 5757f2582b | |||
| d5e6411c45 | |||
| f0c03ceede | |||
| 7818eb104e | |||
| b72514bb32 | |||
| f51d3a2101 | |||
| 5de4055f9f | |||
| 95da43ea0f | |||
| ae47c5f3e6 | |||
| 31ffeba69b | |||
| 963a504ae2 | |||
| ead298c132 | |||
| 2f805e54b7 | |||
| 6158a890af | |||
| 240ceb3808 | |||
| 1459fe9bc8 | |||
| 37164dbdbc | |||
| c3ff92a8c1 | |||
| 371d0c93a5 | |||
| 644c59bf78 | |||
| 056b6fc9d6 | |||
| 83917b3786 | |||
| 955ad78f13 | |||
| 3f04449a86 | |||
| 144a54c1ad | |||
| 90b4b2a038 | |||
| cdfab26c75 | |||
| a389f2940e | |||
| 5e829fc7e7 | |||
| 9c909b6e42 | |||
| 9b29eabfaa | |||
| 7549c988f4 | |||
| 0145be4bbd | |||
| b1207770ca | |||
| d694243723 | |||
| 8e7191e0ea | |||
| dbcf9d93ca | |||
| 27519d0f62 | |||
| 2999d4b65e | |||
| 0fd32ecd34 | |||
| 604a2065cb | |||
| 0aeef1b87e | |||
| b65f10ed8e | |||
| 038fcddd48 | |||
| 394b27ee4a | |||
| 9de59db718 | |||
| 6734de8389 | |||
| 3cb31d07f1 | |||
| 16b9123af3 | |||
| 51d1f075f5 | |||
| e0a68d47a5 | |||
| 832cb51dd3 | |||
| f6256c10db | |||
| 7b2002c1b5 | |||
| d150cf2e72 | |||
| 3921d9b982 | |||
| 9e4c20a4b1 | |||
| f26bca13f1 | |||
| 46c6f2f294 | |||
| 3ad9198f36 | |||
| dafac734e6 | |||
| 97bbc18121 | |||
| 46deb0f726 | |||
| daacf08a54 | |||
| cc2a5c9d58 | |||
| 26f8deff17 | |||
| fb3063e94e | |||
| 83f89e9394 | |||
| 5db02313a1 | |||
| b50e212f05 | |||
| 85f8522085 | |||
| a38c795124 | |||
| 7004104873 | |||
| 7a4a31b52d | |||
| 898c2e72ae | |||
| 961f23b5ea |
@@ -0,0 +1,33 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
pypi:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Environment and permissions trusted publishing.
|
||||
environment:
|
||||
# Create this environment in the GitHub repository under Settings -> Environments
|
||||
name: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
run: uv build
|
||||
- name: Smoke test (wheel)
|
||||
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
|
||||
- name: Smoke test (source distribution)
|
||||
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
|
||||
- name: Publish
|
||||
run: uv publish
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -25,6 +25,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
|
||||
- name: Install OIDC app composer dependencies
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
@@ -33,11 +52,11 @@ jobs:
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
uv run playwright install firefox --with-deps
|
||||
uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Wait for service to be ready
|
||||
run: |
|
||||
@@ -62,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --browser firefox
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
|
||||
+3
-1
@@ -4,4 +4,6 @@ __pycache__/
|
||||
*.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.nextcloud_oauth_test_client.json
|
||||
|
||||
# Generated by pytest used to login users
|
||||
.nextcloud_oauth_*.json
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[submodule "oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
@@ -1,3 +1,99 @@
|
||||
## v0.18.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
|
||||
### Fix
|
||||
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
|
||||
### Refactor
|
||||
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
|
||||
## v0.17.1 (2025-10-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- **caldav**: Fix caldav search() due to missing todos
|
||||
|
||||
## v0.17.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **caldav**: Add support for tasks
|
||||
|
||||
### Fix
|
||||
|
||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate from internal CalendarClient to caldav library
|
||||
|
||||
## v0.16.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **webdav**: Add search and list favorite response tools
|
||||
|
||||
### Perf
|
||||
|
||||
- **notes**: Improve notes search performance using async iterators
|
||||
|
||||
## v0.15.2 (2025-10-17)
|
||||
|
||||
### Refactor
|
||||
|
||||
- Unify logging & remove factory deployment
|
||||
|
||||
## v0.15.1 (2025-10-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- Increase HTTP client timeout to 30s
|
||||
- Handle RequestError in mcp tools
|
||||
|
||||
## v0.15.0 (2025-10-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||
|
||||
## v0.14.3 (2025-10-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.18,<1.19
|
||||
|
||||
## v0.14.2 (2025-10-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency pillow to v12
|
||||
|
||||
## v0.14.1 (2025-10-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **oauth**: Remove the option to force_register new clients
|
||||
|
||||
## v0.14.0 (2025-10-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add Groups API client
|
||||
- add sharing API client and server tools
|
||||
- **users**: Initialize user API client
|
||||
|
||||
### Fix
|
||||
|
||||
- Update user/groups API to OCS v2
|
||||
|
||||
## v0.13.0 (2025-10-13)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -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
|
||||
@@ -38,13 +85,21 @@ mcp run --transport sse nextcloud_mcp_server.app:mcp
|
||||
# Docker development environment with Nextcloud instance
|
||||
docker-compose up
|
||||
|
||||
# After code changes, rebuild and restart only the MCP server container
|
||||
# After code changes, rebuild and restart the appropriate MCP server container:
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# For OAuth changes - uses OAuth authentication flow
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Build Docker image
|
||||
docker build -t nextcloud-mcp-server .
|
||||
```
|
||||
|
||||
**Important: Two MCP Server Containers**
|
||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
@@ -81,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
|
||||
@@ -94,72 +159,92 @@ Each Nextcloud app has a corresponding server module that:
|
||||
4. **Context injection** - MCP context provides access to the authenticated client instance
|
||||
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
|
||||
|
||||
### MCP Response Patterns
|
||||
|
||||
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
|
||||
|
||||
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
|
||||
|
||||
**Pattern:**
|
||||
1. Client methods return `List[Dict]` (raw data)
|
||||
2. MCP tools convert to Pydantic models and wrap in response object
|
||||
3. Response models inherit from `BaseResponse`, include `results` field + metadata
|
||||
|
||||
**Reference implementations:**
|
||||
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80`
|
||||
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113`
|
||||
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py`
|
||||
|
||||
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **Integration tests** in `tests/integration/` - Test real Nextcloud API interactions
|
||||
- **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 to the MCP server, rebuild only the MCP container with `docker-compose up --build -d mcp` before running tests
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||
|
||||
#### Testing Best Practices
|
||||
- **MANDATORY: Always run tests after implementing features or fixing bugs**
|
||||
- Run tests to completion before considering any task complete
|
||||
- If tests require modifications to pass, ask for permission before proceeding
|
||||
- Use `docker-compose up --build -d mcp` to rebuild MCP container after code changes
|
||||
- **Rebuild the correct container** after code changes:
|
||||
- For basic auth tests (most common): `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
|
||||
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing
|
||||
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
|
||||
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
|
||||
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
|
||||
- `temporary_note` - Creates and cleans up test notes automatically
|
||||
- `temporary_addressbook` - Creates and cleans up test address books
|
||||
- `temporary_contact` - Creates and cleans up test contacts
|
||||
- **Test specific functionality** after changes:
|
||||
- For Notes changes: `uv run pytest tests/integration/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/integration/test_notes_api.py -v`
|
||||
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
|
||||
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows:
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**Automated Testing (Default - Recommended for CI/CD):**
|
||||
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default
|
||||
- Uses Playwright headless browser automation to complete OAuth flow programmatically
|
||||
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
|
||||
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
**OAuth Testing Setup:**
|
||||
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||
- Stored in `.nextcloud_oauth_shared_test_client.json`
|
||||
- Matches production MCP server behavior
|
||||
- Each user gets their own unique access token
|
||||
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
|
||||
- Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- Install browsers: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
- Example:
|
||||
```bash
|
||||
# Run all OAuth tests with automated Playwright flow using Firefox
|
||||
uv run pytest tests/integration/test_oauth*.py --browser firefox -v
|
||||
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
|
||||
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
|
||||
|
||||
# Run specific Playwright tests with visible browser for debugging
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox --headed -v
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/integration/test_oauth.py -v
|
||||
```
|
||||
# Run specific tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
|
||||
**Interactive Testing (Manual browser login):**
|
||||
- Opens system browser and waits for manual login/authorization
|
||||
- Fixtures: `interactive_oauth_token`, `nc_oauth_client_interactive`, `nc_mcp_oauth_client_interactive`
|
||||
- Requires: User to complete browser-based login when prompted
|
||||
- Useful for: Debugging OAuth flows, testing with 2FA, local development
|
||||
- **Automatically skipped in GitHub Actions CI** - Interactive fixtures check for `GITHUB_ACTIONS` environment variable
|
||||
- Example:
|
||||
```bash
|
||||
# Run OAuth tests with interactive flow (will open browser and wait for manual login)
|
||||
uv run pytest tests/integration/test_oauth_interactive.py -v
|
||||
```
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -v
|
||||
```
|
||||
|
||||
**Test Environment Setup:**
|
||||
**Test Environment:**
|
||||
- **Two MCP server containers are available:**
|
||||
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
|
||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- OAuth server runs on port 8001 (regular MCP on 8000)
|
||||
- Both flows register OAuth clients dynamically using Nextcloud's OIDC provider
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
|
||||
**CI/CD Considerations:**
|
||||
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
|
||||
- Automated Playwright tests will run in CI/CD environments
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
|
||||
|
||||
### Configuration Files
|
||||
@@ -167,3 +252,15 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
|
||||
## Integration testing with docker
|
||||
|
||||
### Nextcloud
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.2-python3.11-alpine@sha256:59c7cb3e4a4fe9ccff6a5bf0d952a0b1b0101adda48e305c02beea3c22256208
|
||||
FROM ghcr.io/astral-sh/uv:0.9.5-python3.11-alpine@sha256:64ecec379ff82bea84b8a80c0b374f6392bcd54aa52f8c63c12f510f9c0b214d
|
||||
|
||||
# Install git (required for caldav dependency from git)
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -6,19 +6,36 @@
|
||||
|
||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
||||
|
||||
## Features
|
||||
> [!NOTE]
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also _[exposes an MCP server](https://docs.nextcloud.com/server/stable/admin_manual/ai/app_context_agent.html#using-nextcloud-mcp-server)_ for external MCP clients.
|
||||
>
|
||||
> This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. It does not require any additional AI-features to be enabled in Nextcloud beyond the apps that you intend to interact with.
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
|
||||
|
||||
| App | Support | Features |
|
||||
|-----|---------|----------|
|
||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
||||
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
|
||||
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|
||||
|--------|---------------------------------------------|--------------------------------------------------------|
|
||||
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
|
||||
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
|
||||
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
|
||||
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
|
||||
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
|
||||
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
|
||||
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
|
||||
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
|
||||
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
|
||||
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
|
||||
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
|
||||
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
|
||||
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
|
||||
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
|
||||
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
|
||||
|
||||
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -26,8 +43,17 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **OAuth2/OIDC** ✅ | 🔒 High | Production, multi-user deployments |
|
||||
| **Basic Auth** ⚠️ | Lower | Development, testing |
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
|
||||
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
|
||||
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
|
||||
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
|
||||
> - **Production use**: Wait for upstream patch to be merged into official releases
|
||||
>
|
||||
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
|
||||
|
||||
OAuth2/OIDC provides secure, per-user authentication with access tokens. See [Authentication Guide](docs/authentication.md) for details.
|
||||
|
||||
@@ -58,29 +84,35 @@ Create a `.env` file:
|
||||
cp env.sample .env
|
||||
```
|
||||
|
||||
**For OAuth (recommended):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
**For Basic Auth:**
|
||||
**For Basic Auth (recommended for most users):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
NEXTCLOUD_USERNAME=your_username
|
||||
NEXTCLOUD_PASSWORD=your_app_password
|
||||
```
|
||||
|
||||
**For OAuth (experimental - requires patches):**
|
||||
```dotenv
|
||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||
```
|
||||
|
||||
See [Configuration Guide](docs/configuration.md) for all options.
|
||||
|
||||
### 3. Set Up Authentication
|
||||
|
||||
**OAuth Setup (recommended):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. Enable dynamic client registration
|
||||
3. Configure Bearer token validation
|
||||
4. Start the server
|
||||
**Basic Auth Setup (recommended):**
|
||||
1. Create an app password in Nextcloud (Settings → Security → Devices & sessions)
|
||||
2. Add credentials to `.env` file
|
||||
3. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for production deployment.
|
||||
**OAuth Setup (experimental):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
|
||||
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration or create an OIDC client with id & secret
|
||||
4. Configure Bearer token validation in `user_oidc`
|
||||
5. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
|
||||
|
||||
### 4. Run the Server
|
||||
|
||||
@@ -88,12 +120,15 @@ See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth S
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start the server
|
||||
# Start with Basic Auth (default)
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Or start with OAuth (experimental - requires patches)
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# Or with Docker
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
The server starts on `http://127.0.0.1:8000` by default.
|
||||
@@ -120,12 +155,15 @@ Or connect from:
|
||||
- **[Authentication](docs/authentication.md)** - OAuth vs BasicAuth
|
||||
- **[Running the Server](docs/running.md)** - Start and manage the server
|
||||
|
||||
### OAuth Documentation
|
||||
### Architecture
|
||||
- **[Comparison with Context Agent](docs/comparison-context-agent.md)** - How this MCP server differs from Nextcloud's Context Agent
|
||||
|
||||
### OAuth Documentation (Experimental)
|
||||
- **[OAuth Quick Start](docs/quickstart-oauth.md)** - 5-minute setup guide
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Production deployment
|
||||
- **[OAuth Setup Guide](docs/oauth-setup.md)** - Detailed setup instructions
|
||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works
|
||||
- **[OAuth Troubleshooting](docs/oauth-troubleshooting.md)** - OAuth-specific issues
|
||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - Required patches and PRs
|
||||
- **[Upstream Status](docs/oauth-upstream-status.md)** - **Required patches and PRs** ⚠️
|
||||
|
||||
### Reference
|
||||
- **[Troubleshooting](docs/troubleshooting.md)** - Common issues and solutions
|
||||
@@ -134,6 +172,7 @@ Or connect from:
|
||||
- [Notes API](docs/notes.md)
|
||||
- [Calendar (CalDAV)](docs/calendar.md)
|
||||
- [Contacts (CardDAV)](docs/contacts.md)
|
||||
- [Cookbook](docs/cookbook.md)
|
||||
- [Deck](docs/deck.md)
|
||||
- [Tables](docs/table.md)
|
||||
- [WebDAV](docs/webdav.md)
|
||||
@@ -145,6 +184,7 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r
|
||||
### Tools
|
||||
Tools enable AI assistants to perform actions:
|
||||
- `nc_notes_create_note` - Create a new note
|
||||
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||
- `deck_create_card` - Create a Deck card
|
||||
- `nc_calendar_create_event` - Create a calendar event
|
||||
- `nc_contacts_create_contact` - Create a contact
|
||||
@@ -153,6 +193,7 @@ Tools enable AI assistants to perform actions:
|
||||
### Resources
|
||||
Resources provide read-only access to Nextcloud data:
|
||||
- `nc://capabilities` - Server capabilities
|
||||
- `cookbook://version` - Cookbook app version info
|
||||
- `nc://Deck/boards/{board_id}` - Deck board data
|
||||
- `notes://settings` - Notes app settings
|
||||
- And more...
|
||||
@@ -167,6 +208,12 @@ AI: "Create a note called 'Meeting Notes' with today's agenda"
|
||||
→ Uses nc_notes_create_note tool
|
||||
```
|
||||
|
||||
### Manage Recipes
|
||||
```
|
||||
AI: "Import the recipe from this URL: https://www.example.com/recipe/chocolate-cake"
|
||||
→ Uses nc_cookbook_import_recipe tool to extract schema.org metadata
|
||||
```
|
||||
|
||||
### Manage Calendar
|
||||
```
|
||||
AI: "Schedule a team meeting for next Tuesday at 2pm"
|
||||
@@ -214,7 +261,8 @@ Contributions are welcome!
|
||||
[](https://mseep.ai/app/cbcoutinho-nextcloud-mcp-server)
|
||||
|
||||
This project takes security seriously:
|
||||
- OAuth2/OIDC support for secure authentication
|
||||
- OAuth2/OIDC support (experimental - requires upstream patches)
|
||||
- Basic Auth with app-specific passwords (recommended)
|
||||
- No credential storage with OAuth mode
|
||||
- Per-user access tokens
|
||||
- Regular security assessments
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
diff --git a/lib/Util/DiscoveryGenerator.php b/lib/Util/DiscoveryGenerator.php
|
||||
index ee3cd57..6429f94 100644
|
||||
--- a/lib/Util/DiscoveryGenerator.php
|
||||
+++ b/lib/Util/DiscoveryGenerator.php
|
||||
@@ -171,6 +171,11 @@ class DiscoveryGenerator
|
||||
$discoveryPayload['registration_endpoint'] = $host . $this->urlGenerator->linkToRoute('oidc.DynamicRegistration.registerClient', []);
|
||||
}
|
||||
|
||||
+ // Add PKCE support if enabled
|
||||
+ if ($this->appConfig->getAppValueBool('proof_key_for_code_exchange', false)) {
|
||||
+ $discoveryPayload['code_challenge_methods_supported'] = ['S256'];
|
||||
+ }
|
||||
+
|
||||
$this->logger->info('Request to Discovery Endpoint.');
|
||||
|
||||
$response = new JSONResponse($discoveryPayload);
|
||||
+5
-1
@@ -6,14 +6,18 @@ echo "Installing and configuring Calendar app..."
|
||||
|
||||
# Enable calendar app
|
||||
php /var/www/html/occ app:enable calendar
|
||||
php /var/www/html/occ app:enable tasks
|
||||
|
||||
# Wait for calendar app to be fully initialized
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
# Disable rate limits on calendar creation for integration tests
|
||||
# Set to -1 to completely disable rate limiting
|
||||
# Reference: https://docs.nextcloud.com/server/stable/admin_manual/groupware/calendar.html#rate-limits
|
||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
php occ config:app:set dav maximumCalendarsSubscriptions --type=integer --value=-1
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable cookbook
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/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
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring user_oidc app for testing..."
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC apps for testing..."
|
||||
|
||||
# Enable the OIDC Identity Provider app
|
||||
php /var/www/html/occ app:enable oidc
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
|
||||
echo "OIDC apps installed and configured successfully"
|
||||
+39
-4
@@ -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,6 +42,14 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
mcp:
|
||||
build: .
|
||||
@@ -63,13 +74,37 @@ services:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.01:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||
# No USERNAME/PASSWORD - will use OAuth
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email 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:
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
# MCP Server Comparison: Nextcloud MCP Server vs Context Agent
|
||||
|
||||
This document compares the two MCP server implementations in the Nextcloud ecosystem:
|
||||
|
||||
1. **Nextcloud MCP Server** (this project) - Standalone MCP server for external access to Nextcloud
|
||||
2. **Context Agent MCP Server** - MCP server embedded within Nextcloud as an External App
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Both projects expose Nextcloud functionality via the Model Context Protocol (MCP), but serve different purposes and audiences:
|
||||
|
||||
- **Nextcloud MCP Server**: Brings Nextcloud OUT to external MCP clients (Claude Code, etc.)
|
||||
- **Context Agent**: Brings external MCP servers IN to Nextcloud's AI Assistant
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph External["External Clients"]
|
||||
CC[Claude Code]
|
||||
IDE[IDEs with MCP]
|
||||
APP[Other MCP Clients]
|
||||
end
|
||||
|
||||
subgraph NMCP["Nextcloud MCP Server<br/>(This Project)"]
|
||||
NMCP_Server[FastMCP Server]
|
||||
NMCP_Client[HTTP Clients]
|
||||
NMCP_Auth[OAuth/BasicAuth]
|
||||
end
|
||||
|
||||
subgraph NC["Nextcloud Instance"]
|
||||
subgraph CA["Context Agent ExApp"]
|
||||
CA_Agent[LangGraph Agent]
|
||||
CA_MCP[MCP Server /mcp]
|
||||
CA_Tools[Tool Loader]
|
||||
end
|
||||
|
||||
NC_Apps[Nextcloud Apps<br/>Notes, Calendar, Files, etc.]
|
||||
NC_Assistant[Assistant App]
|
||||
end
|
||||
|
||||
subgraph ExtMCP["External MCP Servers"]
|
||||
Weather[Weather MCP]
|
||||
Other[Other Services]
|
||||
end
|
||||
|
||||
%% External clients connect to standalone MCP server
|
||||
CC --> NMCP_Server
|
||||
IDE --> NMCP_Server
|
||||
APP --> NMCP_Server
|
||||
|
||||
%% Standalone MCP server talks to Nextcloud over HTTP
|
||||
NMCP_Server --> NMCP_Auth
|
||||
NMCP_Auth --> NMCP_Client
|
||||
NMCP_Client -->|HTTP/HTTPS| NC_Apps
|
||||
|
||||
%% Context Agent is inside Nextcloud
|
||||
CA_Agent --> CA_Tools
|
||||
CA_Tools --> NC_Apps
|
||||
CA_MCP -->|Exposes to| NC_Assistant
|
||||
NC_Assistant -->|User requests| CA_Agent
|
||||
|
||||
%% Context Agent can consume external MCP servers
|
||||
CA_Tools -->|Consumes| ExtMCP
|
||||
|
||||
%% Context Agent could consume Nextcloud MCP Server
|
||||
CA_Tools -.->|Could consume| NMCP_Server
|
||||
|
||||
classDef external fill:#e1f5ff
|
||||
classDef standalone fill:#fff4e1
|
||||
classDef internal fill:#e8f5e9
|
||||
|
||||
class CC,IDE,APP external
|
||||
class NMCP_Server,NMCP_Client,NMCP_Auth standalone
|
||||
class CA_Agent,CA_MCP,CA_Tools,NC_Apps,NC_Assistant internal
|
||||
```
|
||||
|
||||
## Deployment Models
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph Deploy1["Nextcloud MCP Server Deployment"]
|
||||
direction TB
|
||||
D1[Docker Container]
|
||||
D2[Cloud VM]
|
||||
D3[Local Machine]
|
||||
D4[Kubernetes Pod]
|
||||
end
|
||||
|
||||
subgraph Deploy2["Context Agent Deployment"]
|
||||
direction TB
|
||||
NC[Nextcloud Instance<br/>with AppAPI]
|
||||
ExApp[External App Container<br/>Managed by Nextcloud]
|
||||
end
|
||||
|
||||
Deploy1 -.->|HTTP/HTTPS| NC
|
||||
ExApp -->|Integrated| NC
|
||||
|
||||
classDef deploy fill:#fff4e1
|
||||
classDef integrated fill:#e8f5e9
|
||||
|
||||
class D1,D2,D3,D4 deploy
|
||||
class NC,ExApp integrated
|
||||
```
|
||||
|
||||
### Nextcloud MCP Server
|
||||
- **Location**: Runs anywhere with network access to Nextcloud
|
||||
- **Deployment**: Docker, VM, local machine, Kubernetes
|
||||
- **Connection**: HTTP/HTTPS to Nextcloud APIs
|
||||
- **Independence**: Fully standalone service
|
||||
|
||||
### Context Agent
|
||||
- **Location**: Runs inside Nextcloud as External App
|
||||
- **Deployment**: Managed by Nextcloud AppAPI
|
||||
- **Connection**: Native nc-py-api integration
|
||||
- **Integration**: Deep Nextcloud integration
|
||||
|
||||
## Authentication Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph NMCP_Auth["Nextcloud MCP Server Authentication"]
|
||||
direction TB
|
||||
Client1[MCP Client]
|
||||
|
||||
subgraph BasicAuth["BasicAuth Mode"]
|
||||
BA_Shared[Shared NextcloudClient]
|
||||
BA_Creds[Username + Password]
|
||||
end
|
||||
|
||||
subgraph OAuth["OAuth Mode"]
|
||||
OAuth_Token[OAuth Token]
|
||||
OAuth_Verify[Token Verifier]
|
||||
OAuth_OIDC[OIDC Discovery]
|
||||
OAuth_Client[Per-Request Client]
|
||||
end
|
||||
|
||||
Client1 -->|Basic Auth| BasicAuth
|
||||
Client1 -->|Bearer Token| OAuth
|
||||
BA_Creds --> BA_Shared
|
||||
OAuth_Token --> OAuth_Verify
|
||||
OAuth_OIDC --> OAuth_Verify
|
||||
OAuth_Verify --> OAuth_Client
|
||||
end
|
||||
|
||||
subgraph CA_Auth["Context Agent Authentication"]
|
||||
direction TB
|
||||
Client2[MCP Client]
|
||||
CA_Header[Authorization Header]
|
||||
CA_OCS[OCS API Validation]
|
||||
CA_User[User Context]
|
||||
CA_NC[nc-py-api Client]
|
||||
|
||||
Client2 --> CA_Header
|
||||
CA_Header --> CA_OCS
|
||||
CA_OCS -->|Extract user_id| CA_User
|
||||
CA_User -->|nc.set_user| CA_NC
|
||||
end
|
||||
|
||||
classDef auth fill:#fff4e1
|
||||
classDef user fill:#e1f5ff
|
||||
|
||||
class BasicAuth,OAuth auth
|
||||
class CA_User user
|
||||
```
|
||||
|
||||
## Tool Registration & Loading
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Startup
|
||||
participant NMCP as Nextcloud MCP<br/>Server
|
||||
participant CA as Context Agent
|
||||
participant Request as Client Request
|
||||
|
||||
Note over Startup,NMCP: Nextcloud MCP Server (Static)
|
||||
Startup->>NMCP: Server starts
|
||||
NMCP->>NMCP: configure_notes_tools(mcp)
|
||||
NMCP->>NMCP: configure_calendar_tools(mcp)
|
||||
NMCP->>NMCP: configure_contacts_tools(mcp)
|
||||
Note over NMCP: Tools registered once<br/>at startup
|
||||
Request->>NMCP: Call tool
|
||||
NMCP->>NMCP: Use pre-registered tool
|
||||
|
||||
Note over Startup,CA: Context Agent (Dynamic)
|
||||
Startup->>CA: Server starts
|
||||
CA->>CA: Install ToolListMiddleware
|
||||
Request->>CA: List tools (or 60s elapsed)
|
||||
CA->>CA: get_tools(nc)
|
||||
CA->>CA: Import all_tools/*.py
|
||||
CA->>CA: Call module.get_tools(nc)
|
||||
CA->>CA: Regenerate tool functions
|
||||
Note over CA: Tools refreshed every 60s<br/>or on demand
|
||||
Request->>CA: Call tool
|
||||
CA->>CA: Regenerate with fresh nc
|
||||
```
|
||||
|
||||
## Tool Definition Patterns
|
||||
|
||||
### Nextcloud MCP Server
|
||||
|
||||
```python
|
||||
# Static registration at startup
|
||||
def configure_notes_tools(mcp: FastMCP):
|
||||
@mcp.tool()
|
||||
async def nc_notes_create_note(
|
||||
title: str,
|
||||
content: str,
|
||||
category: str,
|
||||
ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note"""
|
||||
client = get_client(ctx) # Auto-detects auth mode
|
||||
note_data = await client.notes.create_note(
|
||||
title=title,
|
||||
content=content,
|
||||
category=category
|
||||
)
|
||||
return CreateNoteResponse(
|
||||
id=note_data["id"],
|
||||
title=note_data["title"],
|
||||
etag=note_data["etag"]
|
||||
)
|
||||
|
||||
# Resources for structured data access
|
||||
@mcp.resource("nc://Notes/{note_id}")
|
||||
async def nc_get_note_resource(note_id: int):
|
||||
"""Get user note using note id"""
|
||||
ctx = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Native FastMCP `@mcp.tool()` decorator
|
||||
- Pydantic models for type safety
|
||||
- MCP Resources support
|
||||
- Comprehensive error handling with McpError
|
||||
- Context-based client resolution
|
||||
|
||||
### Context Agent
|
||||
|
||||
```python
|
||||
# Dynamic loading at runtime
|
||||
async def get_tools(nc: Nextcloud):
|
||||
@tool
|
||||
@safe_tool
|
||||
def list_calendars():
|
||||
"""List all existing calendars by name"""
|
||||
principal = nc.cal.principal()
|
||||
calendars = principal.calendars()
|
||||
return ", ".join([cal.name for cal in calendars])
|
||||
|
||||
@tool
|
||||
@dangerous_tool
|
||||
def schedule_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
description: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
attendees: list[str] | None,
|
||||
start_time: str | None,
|
||||
end_time: str | None
|
||||
):
|
||||
"""Create a new event or meeting in a calendar"""
|
||||
# Parse dates and times
|
||||
start_datetime = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
# ... event creation logic
|
||||
principal = nc.cal.principal()
|
||||
calendar = {cal.name: cal for cal in calendars}[calendar_name]
|
||||
calendar.add_event(str(c))
|
||||
return True
|
||||
|
||||
return [list_calendars, schedule_event, ...]
|
||||
|
||||
def get_category_name():
|
||||
return "Calendar and Tasks"
|
||||
|
||||
def is_available(nc: Nextcloud):
|
||||
return True # or check capabilities
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- LangChain `@tool` decorator
|
||||
- `@safe_tool` / `@dangerous_tool` decorators
|
||||
- Dynamic tool regeneration with fresh context
|
||||
- Tools returned as list from async function
|
||||
- Availability checking per module
|
||||
|
||||
## Client Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph NMCP_Client["Nextcloud MCP Server Clients"]
|
||||
direction TB
|
||||
NMCP_Main[NextcloudClient]
|
||||
NMCP_Base[BaseNextcloudClient]
|
||||
|
||||
NMCP_Notes[NotesClient]
|
||||
NMCP_Cal[CalendarClient]
|
||||
NMCP_Contacts[ContactsClient]
|
||||
NMCP_Tables[TablesClient]
|
||||
NMCP_WebDAV[WebDAVClient]
|
||||
NMCP_Deck[DeckClient]
|
||||
|
||||
NMCP_Main --> NMCP_Notes
|
||||
NMCP_Main --> NMCP_Cal
|
||||
NMCP_Main --> NMCP_Contacts
|
||||
NMCP_Main --> NMCP_Tables
|
||||
NMCP_Main --> NMCP_WebDAV
|
||||
NMCP_Main --> NMCP_Deck
|
||||
|
||||
NMCP_Notes -.->|extends| NMCP_Base
|
||||
NMCP_Cal -.->|extends| NMCP_Base
|
||||
NMCP_Contacts -.->|extends| NMCP_Base
|
||||
|
||||
NMCP_Base --> HTTPX["httpx.AsyncClient"]
|
||||
NMCP_Base --> Retry["@retry_on_429"]
|
||||
end
|
||||
|
||||
subgraph CA_Client["Context Agent Client"]
|
||||
direction TB
|
||||
CA_NC["nc-py-api<br/>NextcloudApp"]
|
||||
|
||||
CA_NC --> CA_Cal["nc.cal<br/>CalDAV"]
|
||||
CA_NC --> CA_Talk["nc.talk<br/>Talk API"]
|
||||
CA_NC --> CA_OCS["nc.ocs<br/>OCS API"]
|
||||
CA_NC --> CA_Session["nc._session<br/>HTTP Adapter"]
|
||||
end
|
||||
|
||||
HTTPX -->|"HTTP/HTTPS"| NextcloudAPI["Nextcloud APIs"]
|
||||
CA_Session -->|"HTTP/HTTPS"| NextcloudAPI
|
||||
|
||||
classDef custom fill:#fff4e1
|
||||
classDef native fill:#e8f5e9
|
||||
|
||||
class NMCP_Main,NMCP_Base,NMCP_Notes,NMCP_Cal custom
|
||||
class CA_NC,CA_Cal,CA_Talk,CA_OCS native
|
||||
```
|
||||
|
||||
## Functionality Comparison
|
||||
|
||||
### Available Tools & Features
|
||||
|
||||
| Feature Category | Nextcloud MCP Server | Context Agent MCP |
|
||||
|-----------------|---------------------|-------------------|
|
||||
| **Notes** | ✅ Full CRUD, search, attachments (7 tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV (events, recurring, attendees) | ✅ Schedule events, list calendars, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (address books, contacts) | ✅ Find person, current user details (2 tools) |
|
||||
| **Files** | ✅ Full WebDAV (read, write, directories) | ✅ Get content, folder tree, sharing (3 tools) |
|
||||
| **Tables** | ✅ Row CRUD operations | ❌ Not implemented |
|
||||
| **Deck** | ✅ Boards, stacks, cards | ✅ Create board, add card (2 tools) |
|
||||
| **Talk** | ❌ Not implemented | ✅ List/send messages, create conversation (4 tools) |
|
||||
| **Mail** | ❌ Not implemented | ✅ Send email, list mailboxes (2 tools) |
|
||||
| **AI Features** | ❌ Not implemented | ✅ Image gen, audio2text, doc-gen, context_chat (4 tools) |
|
||||
| **Web Search** | ❌ Not implemented | ✅ DuckDuckGo, YouTube search (2 tools) |
|
||||
| **Location** | ❌ Not implemented | ✅ OpenStreetMap, HERE transit, weather (3 tools) |
|
||||
| **OpenProject** | ❌ Not implemented | ✅ Integration (2 tools) |
|
||||
| **MCP Resources** | ✅ notes://, nc:// URIs | ❌ Not supported |
|
||||
| **External MCP** | ❌ Pure server only | ✅ Consumes external MCP servers |
|
||||
| **Sharing** | ✅ Share management API | ❌ Not implemented |
|
||||
| **Capabilities** | ✅ Server info resource | ❌ Not exposed |
|
||||
|
||||
### Tool Count Summary
|
||||
|
||||
- **Nextcloud MCP Server**: ~50+ tools and resources
|
||||
- Deep integration with specific apps
|
||||
- Full CRUD operations
|
||||
- MCP Resources for structured data
|
||||
|
||||
- **Context Agent**: ~28+ tools
|
||||
- Broader feature coverage
|
||||
- Action-oriented (agent tasks)
|
||||
- Can aggregate external MCP servers
|
||||
|
||||
## Tool Safety & Confirmation
|
||||
|
||||
### Context Agent Safety Model
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Request[User Request] --> Agent[LangGraph Agent]
|
||||
Agent --> Model[LLM generates tool calls]
|
||||
Model --> Check{Tool type?}
|
||||
|
||||
Check -->|"@safe_tool"| Execute[Execute immediately]
|
||||
Check -->|"@dangerous_tool"| Queue[Queue for confirmation]
|
||||
|
||||
Queue --> UserNode[Request user confirmation]
|
||||
UserNode -->|Approved| Execute
|
||||
UserNode -->|Denied| Cancel[Cancel with reason]
|
||||
|
||||
Execute --> Result[Return result to agent]
|
||||
Cancel --> Result
|
||||
|
||||
Result --> Agent
|
||||
|
||||
classDef safe fill:#e8f5e9
|
||||
classDef danger fill:#ffe8e8
|
||||
|
||||
class Execute safe
|
||||
class Queue,UserNode,Cancel danger
|
||||
```
|
||||
|
||||
**Safe Tools** (read-only):
|
||||
- `list_calendars`
|
||||
- `find_person_in_contacts`
|
||||
- `list_talk_conversations`
|
||||
- `get_file_content`
|
||||
- `get_folder_tree`
|
||||
|
||||
**Dangerous Tools** (write operations):
|
||||
- `schedule_event`
|
||||
- `send_message_to_conversation`
|
||||
- `create_public_sharing_link`
|
||||
- `send_email`
|
||||
|
||||
### Nextcloud MCP Server Safety
|
||||
|
||||
**No built-in safety classification**:
|
||||
- All tools treated equally
|
||||
- Relies on MCP client for validation
|
||||
- OAuth scopes could control permissions
|
||||
- User must review all actions
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Nextcloud MCP Server
|
||||
|
||||
```python
|
||||
try:
|
||||
note_data = await client.notes.create_note(...)
|
||||
return CreateNoteResponse(...)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions"
|
||||
))
|
||||
elif e.response.status_code == 413:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Note content too large"
|
||||
))
|
||||
elif e.response.status_code == 409:
|
||||
raise McpError(ErrorData(
|
||||
code=-1,
|
||||
message="Note with this title already exists"
|
||||
))
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Comprehensive HTTP status code handling
|
||||
- User-friendly error messages
|
||||
- Specific error codes
|
||||
- Guidance on resolution
|
||||
|
||||
### Context Agent
|
||||
|
||||
```python
|
||||
def schedule_event(...):
|
||||
"""Create event"""
|
||||
# ... implementation
|
||||
calendar.add_event(str(c))
|
||||
return True # Simple boolean return
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Minimal error handling
|
||||
- Exceptions propagate to agent
|
||||
- LangChain handles retries
|
||||
- Agent interprets failures
|
||||
|
||||
## Use Cases
|
||||
|
||||
### When to Use Nextcloud MCP Server
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Root[Nextcloud MCP Server]
|
||||
|
||||
Root --> ExtAccess[External Access]
|
||||
Root --> OAuth[OAuth Security]
|
||||
Root --> DeepAPI[Deep API Access]
|
||||
Root --> Deploy[Standalone Deployment]
|
||||
|
||||
ExtAccess --> EA1[Claude Code integration]
|
||||
ExtAccess --> EA2[IDE plugins with MCP]
|
||||
ExtAccess --> EA3[Custom MCP clients]
|
||||
ExtAccess --> EA4[Cross-platform tools]
|
||||
|
||||
OAuth --> O1[Token-based auth]
|
||||
OAuth --> O2[OIDC compliance]
|
||||
OAuth --> O3[Per-user permissions]
|
||||
OAuth --> O4[Secure external access]
|
||||
|
||||
DeepAPI --> DA1[Full CRUD operations]
|
||||
DeepAPI --> DA2[Notes management]
|
||||
DeepAPI --> DA3[Calendar CalDAV]
|
||||
DeepAPI --> DA4[Contacts CardDAV]
|
||||
DeepAPI --> DA5[File operations]
|
||||
DeepAPI --> DA6[Table data]
|
||||
|
||||
Deploy --> D1[Docker containers]
|
||||
Deploy --> D2[Cloud VMs]
|
||||
Deploy --> D3[Kubernetes]
|
||||
Deploy --> D4[On-premise servers]
|
||||
|
||||
classDef rootStyle fill:#4a90e2,stroke:#2e5c8a,color:#fff
|
||||
classDef categoryStyle fill:#f39c12,stroke:#d68910,color:#fff
|
||||
classDef itemStyle fill:#e8f5e9,stroke:#81c784
|
||||
|
||||
class Root rootStyle
|
||||
class ExtAccess,OAuth,DeepAPI,Deploy categoryStyle
|
||||
class EA1,EA2,EA3,EA4,O1,O2,O3,O4,DA1,DA2,DA3,DA4,DA5,DA6,D1,D2,D3,D4 itemStyle
|
||||
```
|
||||
|
||||
**Best for**:
|
||||
1. External clients accessing Nextcloud (Claude Code, IDEs)
|
||||
2. OAuth/OIDC authentication requirements
|
||||
3. Full CRUD on Notes, Calendar, Contacts, Tables
|
||||
4. WebDAV file system access
|
||||
5. MCP Resources for structured data
|
||||
6. Flexible deployment scenarios
|
||||
7. Building external integrations
|
||||
|
||||
### When to Use Context Agent MCP Server
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Root[Context Agent MCP]
|
||||
|
||||
Root --> Assistant[AI Assistant]
|
||||
Root --> ActionOriented[Action-Oriented]
|
||||
Root --> MCPAgg[MCP Aggregation]
|
||||
Root --> Safety[Safety Features]
|
||||
|
||||
Assistant --> A1[Nextcloud UI integration]
|
||||
Assistant --> A2[Task Processing API]
|
||||
Assistant --> A3[User requests in Assistant]
|
||||
Assistant --> A4[Human-in-the-loop]
|
||||
|
||||
ActionOriented --> AO1[Send emails]
|
||||
ActionOriented --> AO2[Create calendar events]
|
||||
ActionOriented --> AO3[Post Talk messages]
|
||||
ActionOriented --> AO4[Generate images]
|
||||
ActionOriented --> AO5[Search web]
|
||||
|
||||
MCPAgg --> M1[Consume external MCP servers]
|
||||
MCPAgg --> M2[Weather services]
|
||||
MCPAgg --> M3[Maps and transit]
|
||||
MCPAgg --> M4[Custom integrations]
|
||||
MCPAgg --> M5[Unified tool interface]
|
||||
|
||||
Safety --> S1[Read operations auto-execute]
|
||||
Safety --> S2[Write operations require approval]
|
||||
Safety --> S3[User confirmation flow]
|
||||
Safety --> S4[Agent safety]
|
||||
|
||||
classDef rootStyle fill:#9b59b6,stroke:#6c3483,color:#fff
|
||||
classDef categoryStyle fill:#e74c3c,stroke:#c0392b,color:#fff
|
||||
classDef itemStyle fill:#fff4e1,stroke:#f39c12
|
||||
|
||||
class Root rootStyle
|
||||
class Assistant,ActionOriented,MCPAgg,Safety categoryStyle
|
||||
class A1,A2,A3,A4,AO1,AO2,AO3,AO4,AO5,M1,M2,M3,M4,M5,S1,S2,S3,S4 itemStyle
|
||||
```
|
||||
|
||||
**Best for**:
|
||||
1. AI-driven actions inside Nextcloud UI
|
||||
2. Assistant app integration
|
||||
3. Safe/dangerous tool distinction
|
||||
4. Talk, Mail, Deck operations
|
||||
5. AI features (image gen, audio2text)
|
||||
6. Web search and maps
|
||||
7. Aggregating external MCP servers
|
||||
8. Agent acting on behalf of users
|
||||
|
||||
## Complementary Architecture
|
||||
|
||||
The two MCP servers can work together in complementary ways:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
User[User] -->|Requests AI assistance| Assistant[Nextcloud Assistant App]
|
||||
|
||||
Assistant --> ContextAgent[Context Agent]
|
||||
|
||||
subgraph ContextAgent["Context Agent (Inside Nextcloud)"]
|
||||
direction TB
|
||||
Agent[LangGraph Agent]
|
||||
MCPServer[MCP Server /mcp]
|
||||
ToolLoader[Tool Loader]
|
||||
|
||||
Agent --> ToolLoader
|
||||
ToolLoader --> InternalTools[Internal Tools<br/>Talk, Mail, Calendar]
|
||||
end
|
||||
|
||||
subgraph ExternalMCP["External MCP Ecosystem"]
|
||||
NextcloudMCP[Nextcloud MCP Server<br/>This Project]
|
||||
WeatherMCP[Weather MCP]
|
||||
CustomMCP[Custom MCP Services]
|
||||
end
|
||||
|
||||
ToolLoader -->|Consumes| NextcloudMCP
|
||||
ToolLoader -->|Consumes| WeatherMCP
|
||||
ToolLoader -->|Consumes| CustomMCP
|
||||
|
||||
subgraph ExternalClients["External Clients"]
|
||||
Claude[Claude Code]
|
||||
IDE[IDEs with MCP]
|
||||
end
|
||||
|
||||
Claude -->|Direct access| NextcloudMCP
|
||||
IDE -->|Direct access| NextcloudMCP
|
||||
|
||||
NextcloudMCP -->|OAuth/HTTP| NextcloudApps[Nextcloud Apps<br/>Notes, Calendar, Files]
|
||||
InternalTools -->|nc-py-api| NextcloudApps
|
||||
|
||||
classDef internal fill:#e8f5e9
|
||||
classDef external fill:#e1f5ff
|
||||
classDef mcp fill:#fff4e1
|
||||
|
||||
class Assistant,Agent,MCPServer,ToolLoader,InternalTools,NextcloudApps internal
|
||||
class Claude,IDE external
|
||||
class NextcloudMCP,WeatherMCP,CustomMCP mcp
|
||||
```
|
||||
|
||||
### Example Workflows
|
||||
|
||||
**Workflow 1: External Client → Nextcloud MCP Server**
|
||||
```
|
||||
Claude Code → Nextcloud MCP Server → Nextcloud Notes API
|
||||
```
|
||||
- User asks Claude Code to search notes
|
||||
- Claude Code calls `nc_notes_search_notes` tool
|
||||
- Returns results directly to user
|
||||
|
||||
**Workflow 2: Assistant → Context Agent → Internal Tools**
|
||||
```
|
||||
User → Assistant → Context Agent → Send Email Tool
|
||||
```
|
||||
- User asks Assistant to send an email
|
||||
- Context Agent identifies "send_email" as dangerous
|
||||
- Requests user confirmation
|
||||
- Sends email via nc-py-api
|
||||
|
||||
**Workflow 3: Assistant → Context Agent → External MCP**
|
||||
```
|
||||
User → Assistant → Context Agent → Nextcloud MCP Server → Notes
|
||||
```
|
||||
- User asks Assistant about notes
|
||||
- Context Agent consumes Nextcloud MCP Server as external MCP
|
||||
- Gets notes data via MCP protocol
|
||||
- Returns to user via Assistant
|
||||
|
||||
## Technical Comparison Matrix
|
||||
|
||||
| Aspect | Nextcloud MCP Server | Context Agent MCP |
|
||||
|--------|---------------------|-------------------|
|
||||
| **Framework** | FastMCP (native) | FastMCP + LangChain |
|
||||
| **Tool Decorator** | `@mcp.tool()` | `@tool` from LangChain |
|
||||
| **Tool Loading** | Static (startup) | Dynamic (runtime) |
|
||||
| **Tool Refresh** | No (restart required) | Every 60 seconds |
|
||||
| **Resources** | Yes (`@mcp.resource()`) | No |
|
||||
| **Transports** | SSE, HTTP, Streamable-HTTP | Stateless HTTP only |
|
||||
| **MCP Mode** | Server only | Server + Client (hybrid) |
|
||||
| **Client Type** | httpx (custom HTTP) | nc-py-api (native) |
|
||||
| **Deployment** | Standalone external | Inside Nextcloud (ExApp) |
|
||||
| **Auth** | BasicAuth or OAuth/OIDC | Session-based (ExApp) |
|
||||
| **User Context** | Shared or per-token | Per-request `nc.set_user()` |
|
||||
| **Error Handling** | McpError with codes | Basic exceptions |
|
||||
| **Type Safety** | Pydantic models | Python types |
|
||||
| **Safety Model** | No built-in | Safe/Dangerous classification |
|
||||
| **Dependencies** | FastMCP, httpx, Pydantic | nc-py-api, LangChain, LangGraph |
|
||||
| **Integration** | HTTP APIs | AppAPI + Task Processing |
|
||||
| **External MCP** | No | Yes (consumes) |
|
||||
|
||||
## Summary
|
||||
|
||||
Both MCP servers serve important but different roles in the Nextcloud ecosystem:
|
||||
|
||||
### Nextcloud MCP Server (This Project)
|
||||
- **Purpose**: Expose Nextcloud to external MCP clients
|
||||
- **Strength**: Deep CRUD operations, OAuth security, standalone deployment
|
||||
- **Audience**: External developers, Claude Code users, integration builders
|
||||
|
||||
### Context Agent MCP Server
|
||||
- **Purpose**: Bring AI agent capabilities to Nextcloud users
|
||||
- **Strength**: Action-oriented, safe/dangerous tools, MCP aggregation
|
||||
- **Audience**: Nextcloud users via Assistant app, AI-driven workflows
|
||||
|
||||
**Key Insight**: These are complementary, not competing. Context Agent could even consume Nextcloud MCP Server as one of its external MCP sources, creating a unified ecosystem where:
|
||||
- External clients access Nextcloud via Nextcloud MCP Server
|
||||
- Internal users leverage Context Agent for AI assistance
|
||||
- Context Agent aggregates both internal tools and external MCP servers (including Nextcloud MCP Server)
|
||||
@@ -0,0 +1,189 @@
|
||||
# Cookbook App
|
||||
|
||||
### Cookbook Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `nc_cookbook_import_recipe` | Import a recipe from a URL using schema.org metadata |
|
||||
| `nc_cookbook_create_recipe` | Create a new recipe with all schema.org fields |
|
||||
| `nc_cookbook_get_recipe` | Get a specific recipe by ID |
|
||||
| `nc_cookbook_update_recipe` | Update an existing recipe |
|
||||
| `nc_cookbook_delete_recipe` | Delete a recipe permanently |
|
||||
| `nc_cookbook_list_recipes` | Get all recipes in the database |
|
||||
| `nc_cookbook_search_recipes` | Search for recipes by keywords, tags, and categories |
|
||||
| `nc_cookbook_list_categories` | Get all known recipe categories |
|
||||
| `nc_cookbook_get_recipes_in_category` | Get all recipes in a specific category |
|
||||
| `nc_cookbook_list_keywords` | Get all known recipe keywords/tags |
|
||||
| `nc_cookbook_get_recipes_with_keywords` | Get all recipes that have specific keywords |
|
||||
| `nc_cookbook_set_config` | Set Cookbook app configuration |
|
||||
| `nc_cookbook_reindex` | Trigger a rescan of all recipes into the search database |
|
||||
|
||||
### Cookbook Resources
|
||||
|
||||
| Resource | Description |
|
||||
|----------|-------------|
|
||||
| `cookbook://version` | Get Cookbook app and API version information |
|
||||
| `cookbook://config` | Get Cookbook app configuration |
|
||||
| `nc://Cookbook/{recipe_id}` | Get a specific recipe by ID |
|
||||
|
||||
## Recipe Management
|
||||
|
||||
The server provides complete Nextcloud Cookbook integration, enabling you to manage your recipe collection:
|
||||
|
||||
- **Import recipes from websites** using schema.org metadata
|
||||
- Full CRUD operations for recipes
|
||||
- Search and organize with categories and keywords
|
||||
- Support for structured recipe data (ingredients, instructions, nutrition, etc.)
|
||||
- Configure app settings and trigger reindexing
|
||||
|
||||
### Schema.org Recipe Format
|
||||
|
||||
The Cookbook app uses the [schema.org/Recipe](https://schema.org/Recipe) specification for structured recipe data. This standard format includes:
|
||||
|
||||
- **Basic info**: Name, description, image, URL
|
||||
- **Timing**: Preparation time, cooking time, total time (ISO8601 format like `PT30M`)
|
||||
- **Ingredients**: List of ingredients with quantities
|
||||
- **Instructions**: Step-by-step cooking instructions
|
||||
- **Metadata**: Category, keywords/tags, yield (servings)
|
||||
- **Nutrition**: Optional nutrition information
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Import Recipe from URL
|
||||
|
||||
Many recipe websites include schema.org metadata. The import tool automatically extracts this data:
|
||||
|
||||
```python
|
||||
# Import from a recipe website
|
||||
await nc_cookbook_import_recipe(
|
||||
url="https://www.example.com/recipes/chocolate-cake"
|
||||
)
|
||||
# Returns: Recipe object with all extracted data
|
||||
```
|
||||
|
||||
#### Create Recipe Manually
|
||||
|
||||
```python
|
||||
# Create a new recipe from scratch
|
||||
await nc_cookbook_create_recipe(
|
||||
name="Homemade Pizza",
|
||||
description="Classic homemade pizza with fresh ingredients",
|
||||
ingredients=[
|
||||
"500g pizza dough",
|
||||
"200g tomato sauce",
|
||||
"300g mozzarella cheese",
|
||||
"Fresh basil leaves",
|
||||
"Olive oil"
|
||||
],
|
||||
instructions=[
|
||||
"Preheat oven to 250°C (480°F)",
|
||||
"Roll out the pizza dough",
|
||||
"Spread tomato sauce evenly",
|
||||
"Add mozzarella cheese",
|
||||
"Bake for 10-12 minutes",
|
||||
"Top with fresh basil and olive oil"
|
||||
],
|
||||
category="Main Course",
|
||||
keywords="italian,vegetarian,quick",
|
||||
prep_time="PT20M", # 20 minutes
|
||||
cook_time="PT12M", # 12 minutes
|
||||
total_time="PT32M", # 32 minutes
|
||||
recipe_yield=4 # 4 servings
|
||||
)
|
||||
```
|
||||
|
||||
#### Update Recipe
|
||||
|
||||
```python
|
||||
# Update recipe details (only specified fields are changed)
|
||||
await nc_cookbook_update_recipe(
|
||||
recipe_id=123,
|
||||
description="Updated: Classic homemade pizza - now with video tutorial!",
|
||||
url="https://example.com/videos/pizza-tutorial",
|
||||
keywords="italian,vegetarian,quick,video"
|
||||
)
|
||||
```
|
||||
|
||||
#### Search and Filter
|
||||
|
||||
```python
|
||||
# Search recipes by keyword
|
||||
results = await nc_cookbook_search_recipes(query="chocolate")
|
||||
|
||||
# List all categories
|
||||
categories = await nc_cookbook_list_categories()
|
||||
# Returns: [{"name": "Desserts", "recipe_count": 15}, ...]
|
||||
|
||||
# Get recipes in a category
|
||||
desserts = await nc_cookbook_get_recipes_in_category(category="Desserts")
|
||||
|
||||
# List all keywords/tags
|
||||
keywords = await nc_cookbook_list_keywords()
|
||||
# Returns: [{"name": "chocolate", "recipe_count": 8}, ...]
|
||||
|
||||
# Get recipes with specific tags
|
||||
quick_meals = await nc_cookbook_get_recipes_with_keywords(keywords=["quick", "30min"])
|
||||
```
|
||||
|
||||
#### Manage Configuration
|
||||
|
||||
```python
|
||||
# Configure the Cookbook app
|
||||
await nc_cookbook_set_config(
|
||||
folder="Recipes", # Folder path in user's files
|
||||
update_interval=15, # Auto-rescan every 15 minutes
|
||||
print_image=True # Print images with recipes
|
||||
)
|
||||
|
||||
# Trigger manual reindex after file changes
|
||||
await nc_cookbook_reindex()
|
||||
```
|
||||
|
||||
### Time Format (ISO8601 Duration)
|
||||
|
||||
Recipe times use ISO8601 duration format:
|
||||
|
||||
| Duration | Format | Example |
|
||||
|----------|--------|---------|
|
||||
| 15 minutes | `PT15M` | Prep time |
|
||||
| 1 hour | `PT1H` | Baking time |
|
||||
| 1 hour 30 minutes | `PT1H30M` | Total time |
|
||||
| 45 seconds | `PT45S` | Mixing time |
|
||||
| 2 hours 15 minutes | `PT2H15M` | Slow cooking |
|
||||
|
||||
### Tips for Recipe Import
|
||||
|
||||
**Best practices for importing recipes from URLs:**
|
||||
|
||||
1. **Look for schema.org support**: Most modern recipe sites include schema.org metadata
|
||||
2. **Check import quality**: Review imported recipes for completeness
|
||||
3. **Handle duplicates**: The API prevents duplicate imports by recipe name
|
||||
4. **Edit after import**: Update imported recipes with personal notes or adjustments
|
||||
|
||||
**Common recipe websites with good schema.org support:**
|
||||
- AllRecipes
|
||||
- Food Network
|
||||
- BBC Good Food
|
||||
- Serious Eats
|
||||
- Bon Appétit
|
||||
- Many food blogs using recipe plugins
|
||||
|
||||
### Organizing Your Recipes
|
||||
|
||||
**Categories**: Organize recipes by type (Appetizers, Main Course, Desserts, etc.)
|
||||
- Use `nc_cookbook_list_categories` to see all categories
|
||||
- Filter by category with `nc_cookbook_get_recipes_in_category`
|
||||
|
||||
**Keywords/Tags**: Tag recipes with searchable terms (vegetarian, quick, spicy, etc.)
|
||||
- Use `nc_cookbook_list_keywords` to see all tags
|
||||
- Filter by tags with `nc_cookbook_get_recipes_with_keywords`
|
||||
- Search across all fields with `nc_cookbook_search_recipes`
|
||||
|
||||
**Reindexing**: The Cookbook app maintains a search index
|
||||
- Automatically scans at configured intervals
|
||||
- Manually trigger with `nc_cookbook_reindex` after bulk changes
|
||||
- Required after modifying recipe files directly in WebDAV
|
||||
|
||||
## API Reference
|
||||
|
||||
For detailed API documentation, see the [Nextcloud Cookbook OpenAPI specification](https://github.com/nextcloud/cookbook/tree/master/docs/dev/api/0.1.2).
|
||||
@@ -0,0 +1,899 @@
|
||||
# JWT OAuth Reference - Nextcloud MCP Server
|
||||
|
||||
**Last Updated:** 2025-10-23
|
||||
**Status:** Production Ready
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
|
||||
- [Scope-Based Authorization](#scope-based-authorization)
|
||||
- [Configuration](#configuration)
|
||||
- [Architecture](#architecture)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Production Deployment](#production-deployment)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
|
||||
|
||||
- **Faster validation** - No HTTP call needed for token verification
|
||||
- **Direct scope extraction** - Scopes embedded in token claims
|
||||
- **Dynamic tool filtering** - Users only see tools they have permission to use
|
||||
- **Signature verification** - Cryptographic validation using JWKS
|
||||
|
||||
### Key Features
|
||||
|
||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||
- ✅ **Custom Scopes** - `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 8959 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:
|
||||
|
||||
**JWT with `nc:read` only:**
|
||||
- `list_tools()` returns 36 read-only tools
|
||||
- Write tools are hidden from the tool list
|
||||
|
||||
**JWT with `nc:write` only:**
|
||||
- `list_tools()` returns 54 write-only tools
|
||||
- Read tools are hidden from the tool list
|
||||
|
||||
**JWT with both scopes:**
|
||||
- `list_tools()` returns all 90 tools
|
||||
|
||||
**JWT 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)
|
||||
|
||||
### 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"
|
||||
```
|
||||
|
||||
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 8959's Protected Resource Metadata endpoint:
|
||||
|
||||
**Endpoint:** `GET /.well-known/oauth-protected-resource`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8002",
|
||||
"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:433-488`)
|
||||
- Overrides FastMCP's `list_tools()` method
|
||||
- Filters based on user's JWT token scopes
|
||||
- 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`
|
||||
- Advertises `["nc:read", "nc:write"]`
|
||||
- RFC 8959 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 8959: Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc8959.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
|
||||
@@ -217,11 +217,12 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
|
||||
**How it works**:
|
||||
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
|
||||
2. Calls `/apps/oidc/register` to register new client
|
||||
2. Calls `/apps/oidc/register` to register a client on first startup
|
||||
3. Saves credentials to `.nextcloud_oauth_client.json`
|
||||
4. Re-registers if credentials expire
|
||||
4. Reuses these credentials on subsequent startups
|
||||
5. Re-registers only if credentials are missing or expired
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
### Pre-configured Client
|
||||
|
||||
@@ -295,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:
|
||||
@@ -305,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
|
||||
|
||||
+7
-7
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
|
||||
|
||||
### Mode A: Automatic Registration (Dynamic Client Registration)
|
||||
|
||||
**Best for**: Development, testing, short-lived deployments
|
||||
**Best for**: Development, testing, quick deployments
|
||||
|
||||
**How it works**:
|
||||
- MCP server automatically registers OAuth client at startup
|
||||
- MCP server automatically registers an OAuth client on first startup
|
||||
- Uses Nextcloud's dynamic client registration endpoint
|
||||
- Saves credentials to `.nextcloud_oauth_client.json`
|
||||
- Reuses stored credentials on subsequent restarts
|
||||
- Re-registers automatically if credentials expire
|
||||
|
||||
**Pros**:
|
||||
- Zero configuration required
|
||||
- Quick setup
|
||||
- No manual client management
|
||||
- Automatic credential management
|
||||
|
||||
**Cons**:
|
||||
- Clients expire (default: 1 hour, configurable)
|
||||
- Must re-register on restart if expired
|
||||
- Not ideal for long-running production
|
||||
- Must have dynamic client registration enabled on Nextcloud
|
||||
|
||||
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
|
||||
|
||||
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
|
||||
**Best for**: Production, long-running deployments, stable environments
|
||||
|
||||
**How it works**:
|
||||
- You manually register OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server
|
||||
- You manually register an OAuth client via Nextcloud CLI
|
||||
- Provide client credentials to MCP server via environment variables
|
||||
- Credentials don't expire
|
||||
|
||||
**Pros**:
|
||||
|
||||
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. PKCE Support Advertisement in Discovery
|
||||
### 2. PKCE Support (RFC 7636)
|
||||
|
||||
**Status**: 🟢 **PR Submitted** (Pending Review)
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
|
||||
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
|
||||
|
||||
**Why It Matters**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Some MCP clients may reject providers without proper PKCE advertisement
|
||||
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
|
||||
|
||||
**Current Behavior**:
|
||||
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
|
||||
- PKCE just isn't **advertised** in discovery metadata
|
||||
**Authorization Endpoint** (`/authorize`):
|
||||
- Accepts `code_challenge` and `code_challenge_method` parameters
|
||||
- Validates code_challenge format (43-128 characters, unreserved chars only)
|
||||
- Supports both `S256` (SHA-256) and `plain` challenge methods
|
||||
- Stores challenge and method in database for later verification
|
||||
|
||||
**Recommended Fix**: Update `oidc` app to include:
|
||||
**Token Endpoint** (`/token`):
|
||||
- Accepts `code_verifier` parameter
|
||||
- Verifies code_verifier against stored code_challenge using proper algorithm
|
||||
- Uses constant-time comparison to prevent timing attacks
|
||||
- Enforces code_verifier requirement when PKCE was used in authorization
|
||||
|
||||
**Discovery Document**:
|
||||
```json
|
||||
{
|
||||
"code_challenge_methods_supported": ["S256"]
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
|
||||
**Database**:
|
||||
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
|
||||
- Migration included for existing installations
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
|
||||
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
|
||||
- **Size**: +5 lines added, 0 deleted
|
||||
- **Status**: Open, awaiting review
|
||||
**Why It Mattered**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 7636 PKCE provides security for public clients (no client secret)
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Prevents authorization code interception attacks
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
|
||||
- **Changes**: Complete PKCE implementation (+194 lines)
|
||||
- Authorization flow with code_challenge validation
|
||||
- Token exchange with code_verifier verification
|
||||
- Database schema updates
|
||||
- Discovery document updates
|
||||
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||
|
||||
---
|
||||
|
||||
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
| PR/Issue | Component | Status | Priority | Notes |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
|
||||
|
||||
## What Works Without Patches
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow**:
|
||||
- OIDC discovery
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE
|
||||
- Token exchange
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
|
||||
- Tables API
|
||||
- Custom app APIs
|
||||
|
||||
🟡 **Standards Compliance** (PKCE advertisement):
|
||||
- Full RFC 8414 compliance
|
||||
- MCP client compatibility guarantee
|
||||
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||
- ✅ MCP client compatibility guarantee
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
@@ -171,7 +187,7 @@ The integration test suite validates OAuth functionality:
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Run comprehensive OAuth tests
|
||||
uv run pytest tests/integration/test_oauth_playwright.py --browser firefox -v
|
||||
uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
|
||||
|
||||
# Tests verify:
|
||||
# - OAuth flow completion
|
||||
@@ -221,6 +237,6 @@ Want to help get these patches merged?
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-10-14
|
||||
**Last Updated**: 2025-10-20
|
||||
|
||||
**Next Review**: When PR #584 or issue #1221 has activity
|
||||
**Next Review**: When issue #1221 (Bearer token support) has activity
|
||||
|
||||
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
|
||||
This quick start uses **automatic client registration** which is perfect for:
|
||||
- Development
|
||||
- Testing
|
||||
- Short-lived deployments
|
||||
- Quick deployments
|
||||
|
||||
For **production deployments**, you should:
|
||||
1. Pre-register OAuth clients manually
|
||||
2. Use dedicated client credentials
|
||||
For **production deployments**, consider:
|
||||
1. Pre-registering OAuth client manually
|
||||
2. Using dedicated client credentials that don't expire
|
||||
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# Testing Client Sessions Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document compares different approaches to managing MCP client sessions in integration tests, addressing the fundamental incompatibility between pytest-asyncio's fixture management and anyio's structured concurrency requirements.
|
||||
|
||||
## The Problem
|
||||
|
||||
When using pytest-asyncio with anyio-based libraries (like the MCP Python SDK), session-scoped async generator fixtures encounter a fundamental issue:
|
||||
|
||||
1. **pytest-asyncio** runs fixture teardown in a **new asyncio task** using `runner.run()`
|
||||
2. **anyio** requires that cancel scopes be entered and exited in the **same task**
|
||||
3. This causes `RuntimeError: Attempted to exit cancel scope in a different task than it was entered in`
|
||||
|
||||
This is a **known limitation** documented in the anyio project and is not a bug in either pytest-asyncio or anyio, but rather an inherent incompatibility between their design philosophies.
|
||||
|
||||
## Solution Comparison
|
||||
|
||||
### Solution 1: Native Async Context Managers with Surgical Exception Handling ✅ **IMPLEMENTED**
|
||||
|
||||
**Approach**: Use native `async with` statements for clean code structure, but add targeted exception handling at the pytest fixture level to handle the expected teardown errors.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
async def create_mcp_client_session(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Uses native async context managers for clean LIFO cleanup."""
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
async with streamablehttp_client(url, headers=headers) as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Fixture with surgical exception handling for pytest-asyncio incompatibility."""
|
||||
try:
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp", client_name="Basic MCP"
|
||||
):
|
||||
yield session
|
||||
except RuntimeError as e:
|
||||
# Only catch the specific expected error during pytest teardown
|
||||
if "cancel scope" in str(e) and "different task" in str(e):
|
||||
logger.debug(f"Ignoring expected pytest-asyncio teardown issue: {e}")
|
||||
else:
|
||||
# Unexpected RuntimeError - re-raise to fail the test
|
||||
raise
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ Clean, idiomatic code using native Python context managers
|
||||
- ✅ Exception handling is surgical - only catches the specific expected error
|
||||
- ✅ Unexpected errors still propagate and fail tests
|
||||
- ✅ Can use session-scoped fixtures for performance
|
||||
- ✅ Easy to understand and maintain
|
||||
- ✅ Minimal code changes from original implementation
|
||||
- ✅ No external dependencies required
|
||||
|
||||
**Cons**:
|
||||
- ⚠️ Still requires exception suppression (though targeted)
|
||||
- ⚠️ String-based exception matching is somewhat fragile
|
||||
- ⚠️ Must apply the pattern to each session-scoped fixture
|
||||
- ⚠️ Doesn't solve the root cause
|
||||
|
||||
**Verdict**: **Recommended** - Best balance of code clarity, maintainability, and pragmatism.
|
||||
|
||||
---
|
||||
|
||||
### Solution 2: Task-Isolated Fixtures
|
||||
|
||||
**Approach**: Run each fixture's client session in an isolated anyio task group, allowing independent cleanup without cross-fixture interference.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Fixture with task isolation for clean teardown."""
|
||||
import anyio
|
||||
|
||||
session_holder = {"session": None}
|
||||
|
||||
async def create_and_hold_session():
|
||||
"""Runs in isolated task - creates session and keeps it alive."""
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
session_holder["session"] = session
|
||||
|
||||
# Keep session alive until cancelled
|
||||
try:
|
||||
await anyio.sleep_forever()
|
||||
except anyio.get_cancelled_exc_class():
|
||||
pass # Expected cancellation
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(create_and_hold_session)
|
||||
|
||||
# Wait for session to be ready
|
||||
while session_holder["session"] is None:
|
||||
await anyio.sleep(0.1)
|
||||
|
||||
yield session_holder["session"]
|
||||
|
||||
# Task group cancellation ensures clean LIFO cleanup
|
||||
tg.cancel_scope.cancel()
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No exception suppression needed
|
||||
- ✅ Each fixture has its own isolated task scope
|
||||
- ✅ More theoretically correct approach
|
||||
- ✅ Can use session-scoped fixtures
|
||||
|
||||
**Cons**:
|
||||
- ❌ Significantly more complex code
|
||||
- ❌ Harder to understand for developers unfamiliar with anyio
|
||||
- ❌ Requires understanding of task groups and cancel scopes
|
||||
- ❌ More boilerplate per fixture
|
||||
- ❌ Still doesn't solve the fundamental pytest-asyncio incompatibility
|
||||
- ❌ Polling for session readiness is inelegant
|
||||
- ❌ Higher cognitive overhead for maintenance
|
||||
|
||||
**Verdict**: **Not Recommended** - Complexity outweighs benefits. Consider only if exception handling is completely unacceptable.
|
||||
|
||||
---
|
||||
|
||||
### Solution 3: Function-Scoped Fixtures with Nested Context Managers
|
||||
|
||||
**Approach**: Change fixtures to function scope and rely on Python's context manager nesting for guaranteed LIFO cleanup.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="function") # Changed from session
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Function-scoped fixture with natural LIFO cleanup."""
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read_stream, write_stream, _):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
# For tests needing multiple clients:
|
||||
@pytest.fixture(scope="function")
|
||||
async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSession], Any]:
|
||||
"""Multiple clients with guaranteed LIFO cleanup through nesting."""
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read1, write1, _):
|
||||
async with ClientSession(read1, write1) as session1:
|
||||
await session1.initialize()
|
||||
|
||||
async with streamablehttp_client("http://localhost:8001/mcp") as (read2, write2, _):
|
||||
async with ClientSession(read2, write2) as session2:
|
||||
await session2.initialize()
|
||||
yield session1, session2
|
||||
# Cleanup: session2 -> stream2 -> session1 -> stream1 (LIFO guaranteed)
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No exception handling needed
|
||||
- ✅ Simplest to understand
|
||||
- ✅ Natural LIFO cleanup through Python's context managers
|
||||
- ✅ Each test gets fresh clients (better isolation)
|
||||
- ✅ No workarounds or hacks required
|
||||
|
||||
**Cons**:
|
||||
- ❌ Significantly slower tests (new clients per test)
|
||||
- ❌ Cannot share client state across tests
|
||||
- ❌ More resource intensive
|
||||
- ❌ Higher overhead for test suite execution
|
||||
- ❌ May not be practical for expensive fixtures (e.g., OAuth tokens)
|
||||
- ❌ Nested context managers become unwieldy with many clients
|
||||
|
||||
**Verdict**: **Good Alternative** - Consider for specific fixtures where session scope isn't critical, or for new test files where performance isn't a concern.
|
||||
|
||||
---
|
||||
|
||||
### Solution 4: Use pytest-trio Instead of pytest-asyncio (Future)
|
||||
|
||||
**Approach**: Replace pytest-asyncio with pytest-trio, which was designed with structured concurrency in mind.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```python
|
||||
# pyproject.toml
|
||||
[tool.pytest.ini_options]
|
||||
# Remove: asyncio_mode = "auto"
|
||||
# Add: trio_mode = "auto"
|
||||
|
||||
# Fixtures work naturally with trio
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- ✅ No workarounds needed
|
||||
- ✅ Designed for structured concurrency
|
||||
- ✅ Theoretically cleanest solution
|
||||
- ✅ Can use session-scoped fixtures naturally
|
||||
|
||||
**Cons**:
|
||||
- ❌ Requires switching from asyncio to trio backend
|
||||
- ❌ Major refactoring required
|
||||
- ❌ May break existing code that assumes asyncio
|
||||
- ❌ Dependency changes throughout project
|
||||
- ❌ Team needs to learn trio ecosystem
|
||||
- ❌ Less ecosystem support than asyncio
|
||||
|
||||
**Verdict**: **Not Practical** - Too disruptive for existing projects. Consider only for greenfield projects or major rewrites.
|
||||
|
||||
---
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
| Solution | Code Clarity | Maintenance | Performance | Safety | Effort |
|
||||
|----------|--------------|-------------|-------------|--------|--------|
|
||||
| **Solution 1** (Implemented) | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| Solution 2 (Task-Isolated) | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| Solution 3 (Function-Scoped) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| Solution 4 (pytest-trio) | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐ |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### What Changed in Solution 1
|
||||
|
||||
1. **`create_mcp_client_session` function** (conftest.py:61-110):
|
||||
- Replaced manual `__aenter__`/`__aexit__` calls with native `async with` statements
|
||||
- Removed blanket exception suppression from cleanup logic
|
||||
- Added clear documentation about LIFO cleanup order
|
||||
- Simplified from ~60 lines to ~40 lines
|
||||
|
||||
2. **Session-scoped MCP client fixtures** (conftest.py:148-1269):
|
||||
- Added targeted exception handling wrapper
|
||||
- Only catches specific "cancel scope" + "different task" RuntimeError
|
||||
- All other exceptions propagate normally
|
||||
- Applied to: `nc_mcp_client`, `nc_mcp_oauth_client`, `alice_mcp_client`, `bob_mcp_client`, `charlie_mcp_client`, `diana_mcp_client`
|
||||
|
||||
3. **Documentation**:
|
||||
- Added comprehensive docstrings explaining the workaround
|
||||
- Referenced MCP SDK issue #577 for context
|
||||
- Documented why this is necessary and not a bug
|
||||
|
||||
### Benefits of This Implementation
|
||||
|
||||
1. **Clean Core Logic**: The `create_mcp_client_session` function is now clean, idiomatic Python with no workarounds
|
||||
2. **Isolated Workaround**: Exception handling is confined to pytest fixture level where the issue actually occurs
|
||||
3. **Surgical Exception Handling**: Only catches the specific expected error, not all RuntimeErrors
|
||||
4. **Performance**: Maintains session-scoped fixtures for fast test execution
|
||||
5. **Maintainability**: Easy to understand and modify
|
||||
6. **Safety**: Real errors still cause test failures
|
||||
|
||||
## Testing Results
|
||||
|
||||
All tests pass cleanly with the implementation:
|
||||
|
||||
```bash
|
||||
$ uv run pytest tests/server/test_mcp.py -v
|
||||
============================================= test session starts ==============================================
|
||||
tests/server/test_mcp.py::test_mcp_connectivity PASSED [ 16%]
|
||||
tests/server/test_mcp.py::test_mcp_notes_crud_workflow PASSED [ 33%]
|
||||
tests/server/test_mcp.py::test_mcp_notes_etag_conflict PASSED [ 50%]
|
||||
tests/server/test_mcp.py::test_mcp_webdav_workflow PASSED [ 66%]
|
||||
tests/server/test_mcp.py::test_mcp_resources_access PASSED [ 83%]
|
||||
tests/server/test_mcp.py::test_mcp_calendar_workflow PASSED [100%]
|
||||
============================================== 6 passed in 39.52s ==============================================
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For This Project: Solution 1 ✅
|
||||
|
||||
The implemented solution (Solution 1) is the best fit because:
|
||||
- Minimal disruption to existing tests
|
||||
- Clean, maintainable code
|
||||
- Good performance with session-scoped fixtures
|
||||
- Targeted exception handling that doesn't hide real errors
|
||||
|
||||
### For New Test Files: Consider Solution 3
|
||||
|
||||
For new test files where performance isn't critical, consider using function-scoped fixtures (Solution 3):
|
||||
- No workarounds needed
|
||||
- Perfect code clarity
|
||||
- Better test isolation
|
||||
|
||||
### For Greenfield Projects: Consider Solution 4
|
||||
|
||||
For new projects starting from scratch, consider pytest-trio instead of pytest-asyncio:
|
||||
- Native structured concurrency support
|
||||
- No workarounds needed
|
||||
- Better alignment with modern async Python patterns
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [MCP Python SDK Issue #577](https://github.com/modelcontextprotocol/python-sdk/issues/577) - Original issue report
|
||||
- [Anyio Issue #345](https://github.com/agronholm/anyio/issues/345) - Discussion of fixture limitations
|
||||
- [Nextcloud MCP Note 378555](nextcloud://notes/378555) - Detailed investigation notes
|
||||
- pytest-asyncio documentation: https://pytest-asyncio.readthedocs.io/
|
||||
- anyio structured concurrency guide: https://anyio.readthedocs.io/en/stable/basics.html
|
||||
|
||||
## Appendix: Why Can't This Be Fixed Upstream?
|
||||
|
||||
The incompatibility cannot be "fixed" in either pytest-asyncio or anyio without breaking their core design:
|
||||
|
||||
1. **pytest-asyncio** needs to manage fixture lifecycle across different scopes, requiring separate task creation for cleanup
|
||||
2. **anyio** enforces structured concurrency guarantees by requiring same-task cancel scope entry/exit
|
||||
3. These requirements are fundamentally incompatible
|
||||
|
||||
The maintainers of both projects are aware of this issue, and it's considered an acceptable trade-off given their respective design goals. The recommended approach is to handle it at the application level, as we've done here.
|
||||
@@ -0,0 +1,412 @@
|
||||
# Testing OIDC Consent Feature
|
||||
|
||||
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
|
||||
|
||||
## Setup
|
||||
|
||||
### Volume Mount Configuration
|
||||
|
||||
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
volumes:
|
||||
- ../Software/oidc:/opt/apps/oidc:ro
|
||||
```
|
||||
|
||||
**Why mount outside `/var/www/html/`?**
|
||||
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
|
||||
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
|
||||
- Mounting to `/opt/apps/oidc` avoids rsync entirely
|
||||
- Nextcloud supports multiple app directories via the `apps_paths` configuration
|
||||
|
||||
**How multiple app paths work:**
|
||||
- Nextcloud can load apps from multiple directories
|
||||
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
|
||||
- Apps in default paths (index 0 and 1) are still available
|
||||
- All directories are scanned for apps, but `/opt/apps` is read-only
|
||||
|
||||
This setup allows you to:
|
||||
- Test changes without rebuilding containers
|
||||
- Avoid needing npm/node in the container (JS already built on host)
|
||||
- Iterate quickly on development
|
||||
- Install other Nextcloud apps normally (custom_apps remains writable)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
|
||||
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
|
||||
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
|
||||
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
|
||||
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
|
||||
|
||||
## Starting the Stack
|
||||
|
||||
```bash
|
||||
cd ~/Projects/nextcloud-mcp-server
|
||||
|
||||
# Start fresh (recommended for first test)
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
|
||||
# Wait for initialization (check logs)
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
The post-installation hooks will:
|
||||
1. Configure custom_apps path (already done)
|
||||
2. Enable OIDC app from mounted directory
|
||||
3. Run database migrations (including consent table creation)
|
||||
4. Configure OIDC settings
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
### Before Container Restart
|
||||
|
||||
Before running `docker compose up -d`, the consent feature will NOT be active:
|
||||
- ❌ No `oc_oidc_user_consents` table in database
|
||||
- ❌ Migration 0015 not applied yet
|
||||
- ❌ ConsentController class not loaded
|
||||
- ❌ Consent routes not registered
|
||||
|
||||
You can verify this with:
|
||||
```bash
|
||||
# Check migrations applied (should stop at 0014)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
|
||||
|
||||
# Check for consent table (should return empty)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
|
||||
```
|
||||
|
||||
### After Container Restart
|
||||
|
||||
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
|
||||
- ✅ `oc_oidc_user_consents` table exists
|
||||
- ✅ Migration 0015 (Version0015Date20251123100100) applied
|
||||
- ✅ ConsentController routes registered
|
||||
- ✅ Consent screen appears during OAuth flows
|
||||
|
||||
### Check App Status
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ app:list | grep -A 2 oidc
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
- oidc: 1.10.0 (enabled)
|
||||
```
|
||||
|
||||
### Verify App Paths Configuration
|
||||
|
||||
Verify that `/opt/apps` is registered as an additional app directory:
|
||||
|
||||
```bash
|
||||
# Check configured app paths
|
||||
docker compose exec app php occ config:system:get apps_paths
|
||||
|
||||
# Verify the mount is accessible
|
||||
docker compose exec app ls -la /opt/apps/oidc/
|
||||
|
||||
# Verify custom_apps is writable (for normal app installation)
|
||||
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
|
||||
docker compose exec app rm -f /var/www/html/custom_apps/.test
|
||||
```
|
||||
|
||||
Expected: Output should show multiple app paths including index 2 (/opt/apps).
|
||||
|
||||
### Verify Consent Files
|
||||
|
||||
```bash
|
||||
# Check controller exists in mounted location
|
||||
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
|
||||
|
||||
# Check Vue component exists
|
||||
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
|
||||
|
||||
# Check built JS exists
|
||||
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||
```
|
||||
|
||||
### Verify Database Migration
|
||||
|
||||
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
|
||||
|
||||
```bash
|
||||
# Check if consent table exists
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
|
||||
|
||||
# Check table structure
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
|
||||
|
||||
# Verify migration 0015 was applied
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
|
||||
```
|
||||
|
||||
Expected table structure:
|
||||
- id: int(10) unsigned, auto_increment, primary key
|
||||
- user_id: varchar(256), not null
|
||||
- client_id: int(10) unsigned, not null
|
||||
- scopes_granted: varchar(512), not null
|
||||
- created_at: int(10) unsigned, not null
|
||||
- updated_at: int(10) unsigned, not null
|
||||
- expires_at: int(10) unsigned, nullable
|
||||
|
||||
### Verify Routes
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ router:list | grep consent
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
oidc.Consent.show GET apps/oidc/consent
|
||||
oidc.Consent.grant POST apps/oidc/consent/grant
|
||||
oidc.Consent.deny POST apps/oidc/consent/deny
|
||||
```
|
||||
|
||||
## Testing the Consent Flow
|
||||
|
||||
### 1. Create an OAuth Client
|
||||
|
||||
The JWT client is automatically created by the post-installation hooks:
|
||||
|
||||
```bash
|
||||
# Check if JWT client exists
|
||||
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
|
||||
```
|
||||
|
||||
### 2. Initiate Authorization Flow
|
||||
|
||||
You can test using the MCP OAuth container or manually:
|
||||
|
||||
**Option A: Using MCP OAuth container**
|
||||
```bash
|
||||
# The mcp-oauth-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
|
||||
+346
-105
@@ -5,22 +5,32 @@ 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.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,
|
||||
configure_contacts_tools,
|
||||
configure_cookbook_tools,
|
||||
configure_deck_tools,
|
||||
configure_notes_tools,
|
||||
configure_sharing_tools,
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
@@ -132,6 +142,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="Nextcloud MCP Server",
|
||||
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]:
|
||||
"""
|
||||
@@ -174,8 +273,6 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
|
||||
try:
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -186,45 +283,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")
|
||||
@@ -264,8 +340,6 @@ async def setup_oauth_config():
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Fetch OIDC discovery
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
@@ -279,59 +353,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")
|
||||
@@ -350,9 +425,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,
|
||||
@@ -375,8 +448,10 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"notes": configure_notes_tools,
|
||||
"tables": configure_tables_tools,
|
||||
"webdav": configure_webdav_tools,
|
||||
"sharing": configure_sharing_tools,
|
||||
"calendar": configure_calendar_tools,
|
||||
"contacts": configure_contacts_tools,
|
||||
"cookbook": configure_cookbook_tools,
|
||||
"deck": configure_deck_tools,
|
||||
}
|
||||
|
||||
@@ -394,6 +469,54 @@ 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 tokens only)."""
|
||||
# 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()
|
||||
|
||||
# Only filter for JWT tokens (opaque tokens show all tools)
|
||||
# JWT tokens have scopes embedded, so we can reliably filter
|
||||
# Opaque tokens may not have accurate scope information from introspection
|
||||
if is_jwt and user_scopes:
|
||||
allowed_tools = [
|
||||
tool
|
||||
for tool in all_tools
|
||||
if has_required_scopes(tool.fn, user_scopes)
|
||||
]
|
||||
logger.info(
|
||||
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||
f"available for scopes: {user_scopes}"
|
||||
)
|
||||
else:
|
||||
# Opaque token, BasicAuth mode, or no token - show all tools
|
||||
allowed_tools = all_tools
|
||||
reason = (
|
||||
"opaque token (no filtering)"
|
||||
if not is_jwt and user_scopes
|
||||
else "no token/BasicAuth"
|
||||
)
|
||||
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
|
||||
|
||||
# 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 tokens only)")
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
@@ -406,7 +529,71 @@ 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 8959 Protected Resource Metadata endpoint."""
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
# 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": mcp_server_url,
|
||||
"scopes_supported": ["nc:read", "nc:write"],
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
oauth_protected_resource_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Protected Resource Metadata (PRM) endpoint enabled")
|
||||
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
# Add exception handler for scope challenges (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
|
||||
@app.exception_handler(InsufficientScopeError)
|
||||
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
|
||||
"""Return 403 with WWW-Authenticate header for scope challenges."""
|
||||
resource_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
scope_str = " ".join(exc.missing_scopes)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer error="insufficient_scope", '
|
||||
f'scope="{scope_str}", '
|
||||
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"'
|
||||
)
|
||||
},
|
||||
content={
|
||||
"error": "insufficient_scope",
|
||||
"scopes_required": exc.missing_scopes,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
return app
|
||||
|
||||
@@ -418,10 +605,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@click.option(
|
||||
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||
)
|
||||
@click.option(
|
||||
"--workers", "-w", type=int, default=None, help="Number of worker processes"
|
||||
)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
"-l",
|
||||
@@ -442,7 +625,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"--enable-app",
|
||||
"-e",
|
||||
multiple=True,
|
||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||
type=click.Choice(
|
||||
["notes", "tables", "webdav", "calendar", "contacts", "cookbook", "deck"]
|
||||
),
|
||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||
)
|
||||
@click.option(
|
||||
@@ -474,11 +659,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, ...],
|
||||
@@ -487,6 +705,12 @@ def run(
|
||||
oauth_client_secret: str | None,
|
||||
oauth_storage_path: str,
|
||||
mcp_server_url: str,
|
||||
nextcloud_host: str | None,
|
||||
nextcloud_username: str | None,
|
||||
nextcloud_password: str | None,
|
||||
oauth_scopes: str,
|
||||
oauth_token_type: str,
|
||||
public_issuer_url: str | None,
|
||||
):
|
||||
"""
|
||||
Run the Nextcloud MCP server.
|
||||
@@ -498,24 +722,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:
|
||||
@@ -585,21 +837,10 @@ def run(
|
||||
|
||||
enabled_apps = list(enable_app) if enable_app else None
|
||||
|
||||
if reload or workers:
|
||||
app = "nextcloud_mcp_server.app:get_app"
|
||||
factory = True
|
||||
else:
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
factory = False
|
||||
app = get_app(transport=transport, enabled_apps=enabled_apps)
|
||||
|
||||
uvicorn.run(
|
||||
app=app,
|
||||
factory=factory,
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
workers=workers,
|
||||
log_level=log_level,
|
||||
app=app, host=host, port=port, log_level=log_level, log_config=LOGGING_CONFIG
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,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,7 +218,8 @@ async def load_or_register_client(
|
||||
storage_path: str | Path,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
force_register: bool = True,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -219,7 +227,7 @@ async def load_or_register_client(
|
||||
This function:
|
||||
1. Checks for existing client credentials in storage
|
||||
2. Validates the credentials are not expired
|
||||
3. Registers a new client if needed
|
||||
3. Registers a new client if needed (no stored credentials or expired)
|
||||
4. Saves the new client credentials
|
||||
|
||||
Args:
|
||||
@@ -228,7 +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
|
||||
force_register: Force registration even if valid credentials exist
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -239,11 +248,10 @@ async def load_or_register_client(
|
||||
"""
|
||||
storage_path = Path(storage_path)
|
||||
|
||||
# Try to load existing client unless forced to register
|
||||
if not force_register:
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
# Try to load existing client
|
||||
client_info = load_client_from_file(storage_path)
|
||||
if client_info:
|
||||
return client_info
|
||||
|
||||
# Register new client
|
||||
logger.info("Registering new OAuth client...")
|
||||
@@ -252,6 +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)
|
||||
@@ -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,225 @@ 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.info(f"Token introspection failed: HTTP {response.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}"
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -14,9 +14,13 @@ from httpx import (
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
from .users import UsersClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,9 +72,15 @@ class NextcloudClient:
|
||||
self.notes = NotesClient(self._client, username)
|
||||
self.webdav = WebDAVClient(self._client, username)
|
||||
self.tables = TablesClient(self._client, username)
|
||||
self.calendar = CalendarClient(self._client, username)
|
||||
self.calendar = CalendarClient(
|
||||
base_url, username, auth
|
||||
) # Uses AsyncDavClient internally
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
|
||||
# Initialize controllers
|
||||
self._notes_search = NotesSearchController()
|
||||
@@ -113,13 +123,14 @@ class NextcloudClient:
|
||||
|
||||
async def notes_search_notes(self, *, query: str):
|
||||
"""Search notes using token-based matching with relevance ranking."""
|
||||
all_notes = await self.notes.get_all_notes()
|
||||
return self._notes_search.search_notes(all_notes, query)
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
await self.calendar.close()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,250 @@
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from httpx import Timeout
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CookbookClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Cookbook app operations."""
|
||||
|
||||
async def get_version(self) -> Dict[str, Any]:
|
||||
"""Get Cookbook app and API version."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/version")
|
||||
return response.json()
|
||||
|
||||
async def get_config(self) -> Dict[str, Any]:
|
||||
"""Get current Cookbook app configuration."""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/config")
|
||||
return response.json()
|
||||
|
||||
async def set_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary with fields like:
|
||||
- folder: Recipe folder path
|
||||
- update_interval: Auto-rescan interval in minutes
|
||||
- print_image: Whether to print images with recipes
|
||||
- visibleInfoBlocks: Visible info blocks configuration
|
||||
|
||||
Returns:
|
||||
Response with status message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/config", json=config
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def reindex(self) -> str:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request("POST", "/apps/cookbook/api/v1/reindex")
|
||||
return response.json()
|
||||
|
||||
async def list_recipes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in the database.
|
||||
|
||||
Returns:
|
||||
List of recipe stubs with basic information
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/recipes")
|
||||
return response.json()
|
||||
|
||||
async def get_recipe(self, recipe_id: int) -> Dict[str, Any]:
|
||||
"""Get a single recipe by ID.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
|
||||
Returns:
|
||||
Full recipe data
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def create_recipe(self, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Create a new recipe.
|
||||
|
||||
Args:
|
||||
recipe_data: Recipe data following schema.org/Recipe format.
|
||||
Required: name
|
||||
Optional: description, ingredients, instructions, etc.
|
||||
|
||||
Returns:
|
||||
ID of the newly created recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def update_recipe(self, recipe_id: int, recipe_data: Dict[str, Any]) -> int:
|
||||
"""Update an existing recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to update
|
||||
recipe_data: Updated recipe data
|
||||
|
||||
Returns:
|
||||
ID of the updated recipe
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"PUT", f"/apps/cookbook/api/v1/recipes/{recipe_id}", json=recipe_data
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_recipe(self, recipe_id: int) -> str:
|
||||
"""Delete a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID to delete
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"DELETE", f"/apps/cookbook/api/v1/recipes/{recipe_id}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def import_recipe(self, url: str) -> Dict[str, Any]:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
Args:
|
||||
url: URL of the recipe to import
|
||||
|
||||
Returns:
|
||||
Full imported recipe data
|
||||
"""
|
||||
logger.info(f"Importing recipe from URL: {url}")
|
||||
response = await self._make_request(
|
||||
"POST",
|
||||
"/apps/cookbook/api/v1/import",
|
||||
json={"url": url},
|
||||
timeout=Timeout(300.0),
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def get_recipe_image(self, recipe_id: int, size: str = "full") -> bytes:
|
||||
"""Get the main image of a recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: The recipe ID
|
||||
size: Image size - "full", "thumb" (250px), or "thumb16" (16px)
|
||||
|
||||
Returns:
|
||||
Image bytes
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"/apps/cookbook/api/v1/recipes/{recipe_id}/image",
|
||||
params={"size": size},
|
||||
)
|
||||
return response.content
|
||||
|
||||
async def search_recipes(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""Search for recipes by keywords, tags, and categories.
|
||||
|
||||
Args:
|
||||
query: Search string (URL-encoded, space/comma separated)
|
||||
|
||||
Returns:
|
||||
List of matching recipe stubs
|
||||
"""
|
||||
# URL encode the query
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_query = quote(query)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_categories(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category.
|
||||
|
||||
Returns:
|
||||
List of categories with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/categories")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_in_category(self, category: str) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Args:
|
||||
category: Category name (use "_" for recipes with no category)
|
||||
|
||||
Returns:
|
||||
List of recipe stubs in the category
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_category = quote(category)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def rename_category(self, old_name: str, new_name: str) -> str:
|
||||
"""Rename a category.
|
||||
|
||||
Args:
|
||||
old_name: Current category name
|
||||
new_name: New category name
|
||||
|
||||
Returns:
|
||||
New category name
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_old_name = quote(old_name)
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/cookbook/api/v1/category/{encoded_old_name}",
|
||||
json={"name": new_name},
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def list_keywords(self) -> List[Dict[str, Any]]:
|
||||
"""Get all known keywords/tags.
|
||||
|
||||
Returns:
|
||||
List of keywords with recipe counts
|
||||
"""
|
||||
response = await self._make_request("GET", "/apps/cookbook/api/v1/keywords")
|
||||
return response.json()
|
||||
|
||||
async def get_recipes_with_keywords(
|
||||
self, keywords: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get all recipes associated with certain keywords.
|
||||
|
||||
Args:
|
||||
keywords: List of keywords to filter by
|
||||
|
||||
Returns:
|
||||
List of recipe stubs matching the keywords
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Join keywords with commas
|
||||
keywords_str = ",".join(keywords)
|
||||
encoded_keywords = quote(keywords_str)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/tags/{encoded_keywords}"
|
||||
)
|
||||
return response.json()
|
||||
@@ -99,7 +99,7 @@ class DeckClient(BaseNextcloudClient):
|
||||
permission_edit: bool,
|
||||
permission_share: bool,
|
||||
permission_manage: bool,
|
||||
) -> List[DeckACL]:
|
||||
) -> DeckACL:
|
||||
json_data = {
|
||||
"type": type,
|
||||
"participant": participant,
|
||||
@@ -107,10 +107,14 @@ class DeckClient(BaseNextcloudClient):
|
||||
"permissionShare": permission_share,
|
||||
"permissionManage": permission_manage,
|
||||
}
|
||||
headers = self._get_deck_headers()
|
||||
response = await self._make_request(
|
||||
"POST", f"/apps/deck/api/v1.0/boards/{board_id}/acl", json=json_data
|
||||
"POST",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
return [DeckACL(**acl) for acl in response.json()]
|
||||
return DeckACL(**response.json())
|
||||
|
||||
async def update_acl_rule(
|
||||
self,
|
||||
@@ -127,13 +131,20 @@ class DeckClient(BaseNextcloudClient):
|
||||
json_data["permissionShare"] = permission_share
|
||||
if permission_manage is not None:
|
||||
json_data["permissionManage"] = permission_manage
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}", json=json_data
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def delete_acl_rule(self, board_id: int, acl_id: int) -> None:
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"DELETE", f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}"
|
||||
"DELETE",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/acl/{acl_id}",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def clone_board(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Nextcloud Groups API client."""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from .base import BaseNextcloudClient, retry_on_429
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupsClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud Groups API operations."""
|
||||
|
||||
@retry_on_429
|
||||
async def search_groups(
|
||||
self,
|
||||
search: str | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int | None = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Search for groups on the Nextcloud server.
|
||||
|
||||
Args:
|
||||
search: Optional search string to filter groups
|
||||
limit: Optional limit for number of results
|
||||
offset: Optional offset for pagination
|
||||
|
||||
Returns:
|
||||
List of group IDs matching the search criteria
|
||||
"""
|
||||
params = {}
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/cloud/groups",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
groups = data["ocs"]["data"].get("groups", [])
|
||||
return groups
|
||||
|
||||
@retry_on_429
|
||||
async def create_group(self, groupid: str) -> None:
|
||||
"""
|
||||
Create a new group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID to create
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails (e.g., group already exists)
|
||||
"""
|
||||
response = await self._client.post(
|
||||
"/ocs/v2.php/cloud/groups",
|
||||
data={"groupid": groupid},
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Created group: {groupid}")
|
||||
|
||||
@retry_on_429
|
||||
async def delete_group(self, groupid: str) -> None:
|
||||
"""
|
||||
Delete a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID to delete
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails (e.g., group doesn't exist)
|
||||
"""
|
||||
response = await self._client.delete(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Deleted group: {groupid}")
|
||||
|
||||
@retry_on_429
|
||||
async def get_group_members(self, groupid: str) -> List[str]:
|
||||
"""
|
||||
Get members of a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
|
||||
Returns:
|
||||
List of usernames in the group
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
users = data["ocs"]["data"].get("users", [])
|
||||
return users
|
||||
|
||||
@retry_on_429
|
||||
async def get_group_subadmins(self, groupid: str) -> List[str]:
|
||||
"""
|
||||
Get subadmins of a group.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
|
||||
Returns:
|
||||
List of usernames who are subadmins of the group
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}/subadmins",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The API returns data as a list or dict depending on results
|
||||
subadmins_data = data["ocs"]["data"]
|
||||
if isinstance(subadmins_data, list):
|
||||
return subadmins_data
|
||||
return []
|
||||
|
||||
@retry_on_429
|
||||
async def update_group_displayname(self, groupid: str, displayname: str) -> None:
|
||||
"""
|
||||
Update a group's display name.
|
||||
|
||||
Args:
|
||||
groupid: The group ID
|
||||
displayname: The new display name
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.put(
|
||||
f"/ocs/v2.php/cloud/groups/{groupid}",
|
||||
data={"key": "displayname", "value": displayname},
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Updated group {groupid} displayname to: {displayname}")
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Client for Nextcloud Notes app operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
@@ -16,24 +16,22 @@ class NotesClient(BaseNextcloudClient):
|
||||
response = await self._make_request("GET", "/apps/notes/api/v1/settings")
|
||||
return response.json()
|
||||
|
||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||
"""Get all notes."""
|
||||
notes = []
|
||||
async def get_all_notes(self) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Get all notes, yielding them one at a time."""
|
||||
cursor = ""
|
||||
|
||||
while True:
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
"/apps/notes/api/v1/notes",
|
||||
params={"chunkSize": 50, "chunkCursor": cursor},
|
||||
params={"chunkSize": 10, "chunkCursor": cursor},
|
||||
)
|
||||
notes.extend(response.json())
|
||||
for note in response.json():
|
||||
yield note
|
||||
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||
break
|
||||
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||
|
||||
return notes
|
||||
|
||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||
"""Get a specific note by ID."""
|
||||
response = await self._make_request(
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Nextcloud OCS Sharing API client for file/folder sharing operations."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseNextcloudClient, retry_on_429
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SharingClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud OCS Sharing API operations."""
|
||||
|
||||
@retry_on_429
|
||||
async def create_share(
|
||||
self,
|
||||
path: str,
|
||||
share_with: str,
|
||||
share_type: int = 0,
|
||||
permissions: int = 1,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a share for a file or folder.
|
||||
|
||||
Args:
|
||||
path: Path to file/folder to share (relative to user's files)
|
||||
share_with: Username (for user share) or group name (for group share)
|
||||
share_type: Share type (0=user, 1=group, 3=public link)
|
||||
permissions: Share permissions:
|
||||
- 1 = read
|
||||
- 2 = update
|
||||
- 4 = create
|
||||
- 8 = delete
|
||||
- 16 = share
|
||||
- 31 = all permissions
|
||||
Common combinations: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
|
||||
|
||||
Returns:
|
||||
Share data including share ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.post(
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
data={
|
||||
"path": path,
|
||||
"shareType": share_type,
|
||||
"shareWith": share_with,
|
||||
"permissions": permissions,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# OCS API v2 uses HTTP-style status codes (200 for success)
|
||||
# OCS API v1 used custom codes (100 for success)
|
||||
ocs_status = data["ocs"]["meta"]["statuscode"]
|
||||
if ocs_status not in (100, 200):
|
||||
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
|
||||
raise RuntimeError(f"OCS API error (code {ocs_status}): {ocs_message}")
|
||||
|
||||
share_data = data["ocs"]["data"]
|
||||
|
||||
# Handle case where data might be an empty list on error
|
||||
if not share_data or (isinstance(share_data, list) and len(share_data) == 0):
|
||||
ocs_message = data["ocs"]["meta"].get("message", "Unknown error")
|
||||
raise RuntimeError(
|
||||
f"Share creation failed: {ocs_message} (status {ocs_status})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created share {share_data['id']}: {path} -> {share_with} "
|
||||
f"(type={share_type}, permissions={permissions})"
|
||||
)
|
||||
return share_data
|
||||
|
||||
@retry_on_429
|
||||
async def delete_share(self, share_id: int) -> None:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
Args:
|
||||
share_id: The share ID to delete
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.delete(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
logger.info(f"Deleted share {share_id}")
|
||||
|
||||
@retry_on_429
|
||||
async def get_share(self, share_id: int) -> dict[str, Any]:
|
||||
"""Get information about a specific share.
|
||||
|
||||
Args:
|
||||
share_id: The share ID
|
||||
|
||||
Returns:
|
||||
Share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
response = await self._client.get(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
share_data = data["ocs"]["data"]
|
||||
# The API returns a list with a single share, extract the first element
|
||||
if isinstance(share_data, list) and len(share_data) > 0:
|
||||
return share_data[0]
|
||||
return share_data
|
||||
|
||||
@retry_on_429
|
||||
async def list_shares(
|
||||
self, path: str | None = None, shared_with_me: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List shares.
|
||||
|
||||
Args:
|
||||
path: Optional path to filter shares for a specific file/folder
|
||||
shared_with_me: If True, list shares shared with the current user
|
||||
|
||||
Returns:
|
||||
List of share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
params = {}
|
||||
if path:
|
||||
params["path"] = path
|
||||
if shared_with_me:
|
||||
params["shared_with_me"] = "true"
|
||||
|
||||
response = await self._client.get(
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/shares",
|
||||
params=params,
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {data['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
# Handle both single share and list of shares
|
||||
shares_data = data["ocs"]["data"]
|
||||
if isinstance(shares_data, dict):
|
||||
return [shares_data]
|
||||
return shares_data if shares_data else []
|
||||
|
||||
@retry_on_429
|
||||
async def update_share(
|
||||
self, share_id: int, permissions: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Update a share's permissions.
|
||||
|
||||
Args:
|
||||
share_id: The share ID to update
|
||||
permissions: New permissions value (see create_share for values)
|
||||
|
||||
Returns:
|
||||
Updated share data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If the request fails
|
||||
"""
|
||||
data = {}
|
||||
if permissions is not None:
|
||||
data["permissions"] = permissions
|
||||
|
||||
response = await self._client.put(
|
||||
f"/ocs/v2.php/apps/files_sharing/api/v1/shares/{share_id}",
|
||||
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
|
||||
data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result["ocs"]["meta"]["statuscode"] not in (100, 200):
|
||||
raise RuntimeError(
|
||||
f"OCS API error: {result['ocs']['meta'].get('message', 'Unknown error')}"
|
||||
)
|
||||
|
||||
logger.info(f"Updated share {share_id}")
|
||||
return result["ocs"]["data"]
|
||||
@@ -0,0 +1,223 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from nextcloud_mcp_server.client.base import BaseNextcloudClient
|
||||
from nextcloud_mcp_server.models.users import UserDetails
|
||||
|
||||
|
||||
class UsersClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud User API operations."""
|
||||
|
||||
def _get_user_headers(
|
||||
self, additional_headers: Optional[Dict[str, str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get standard headers required for User API calls."""
|
||||
headers = {"OCS-APIRequest": "true", "Accept": "application/json"}
|
||||
if additional_headers:
|
||||
headers.update(additional_headers)
|
||||
return headers
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
userid: str,
|
||||
password: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
groups: Optional[List[str]] = None,
|
||||
subadmin_groups: Optional[List[str]] = None,
|
||||
quota: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create a new user on the Nextcloud server.
|
||||
"""
|
||||
data = {"userid": userid}
|
||||
if password is not None:
|
||||
data["password"] = password
|
||||
if display_name is not None:
|
||||
data["displayName"] = display_name
|
||||
if email is not None:
|
||||
data["email"] = email
|
||||
if groups is not None:
|
||||
for i, group in enumerate(groups):
|
||||
data[f"groups[{i}]"] = group
|
||||
if subadmin_groups is not None:
|
||||
for i, group in enumerate(subadmin_groups):
|
||||
data[f"subadmin[{i}]"] = group
|
||||
if quota is not None:
|
||||
data["quota"] = quota
|
||||
if language is not None:
|
||||
data["language"] = language
|
||||
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST", "/ocs/v2.php/cloud/users", data=data, headers=headers
|
||||
)
|
||||
|
||||
async def search_users(
|
||||
self,
|
||||
search: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Retrieves a list of users from the Nextcloud server.
|
||||
"""
|
||||
params = {}
|
||||
if search is not None:
|
||||
params["search"] = search
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
if offset is not None:
|
||||
params["offset"] = offset
|
||||
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/cloud/users", params=params, headers=headers
|
||||
)
|
||||
# The v2 API returns JSON with users as a direct list under data.users
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data.get("users", [])
|
||||
|
||||
async def get_user_details(self, userid: str) -> UserDetails:
|
||||
"""
|
||||
Retrieves information about a single user.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
|
||||
)
|
||||
return UserDetails(**response.json()["ocs"]["data"])
|
||||
|
||||
async def update_user_field(self, userid: str, key: str, value: str) -> None:
|
||||
"""
|
||||
Edits attributes related to a user.
|
||||
"""
|
||||
data = {"key": key, "value": value}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}", data=data, headers=headers
|
||||
)
|
||||
|
||||
async def get_editable_user_fields(self) -> List[str]:
|
||||
"""
|
||||
Gets the list of editable data fields for a user.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", "/ocs/v2.php/cloud/user/fields", headers=headers
|
||||
)
|
||||
# The v2 API returns data as a direct list
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def disable_user(self, userid: str) -> None:
|
||||
"""
|
||||
Disables a user on the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}/disable", headers=headers
|
||||
)
|
||||
|
||||
async def enable_user(self, userid: str) -> None:
|
||||
"""
|
||||
Enables a user on the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"PUT", f"/ocs/v2.php/cloud/users/{userid}/enable", headers=headers
|
||||
)
|
||||
|
||||
async def delete_user(self, userid: str) -> None:
|
||||
"""
|
||||
Deletes a user from the Nextcloud server.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE", f"/ocs/v2.php/cloud/users/{userid}", headers=headers
|
||||
)
|
||||
|
||||
async def get_user_groups(self, userid: str) -> List[str]:
|
||||
"""
|
||||
Retrieves a list of groups the specified user is a member of.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}/groups", headers=headers
|
||||
)
|
||||
# The v2 API returns groups as a direct list under data.groups
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data.get("groups", [])
|
||||
|
||||
async def add_user_to_group(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Adds the specified user to the specified group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/groups",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def remove_user_from_group(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Removes the specified user from the specified group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/groups",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def promote_user_to_subadmin(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Makes a user the subadmin of a group.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def demote_user_from_subadmin(self, userid: str, groupid: str) -> None:
|
||||
"""
|
||||
Removes the subadmin rights for the user specified from the group specified.
|
||||
"""
|
||||
data = {"groupid": groupid}
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"DELETE",
|
||||
f"/ocs/v2.php/cloud/users/{userid}/subadmins",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
async def get_user_subadmin_groups(self, userid: str) -> List[str]:
|
||||
"""
|
||||
Returns the groups in which the user is a subadmin.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
response = await self._make_request(
|
||||
"GET", f"/ocs/v2.php/cloud/users/{userid}/subadmins", headers=headers
|
||||
)
|
||||
# The v2 API returns data as a direct list
|
||||
data = response.json()["ocs"]["data"]
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def resend_welcome_email(self, userid: str) -> None:
|
||||
"""
|
||||
Triggers the welcome email for this user again.
|
||||
"""
|
||||
headers = self._get_user_headers()
|
||||
await self._make_request(
|
||||
"POST", f"/ocs/v2.php/cloud/users/{userid}/welcome", headers=headers
|
||||
)
|
||||
@@ -570,3 +570,379 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
f"Unexpected error copying resource from '{source_path}' to '{destination_path}': {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
scope: str = "",
|
||||
where_conditions: Optional[str] = None,
|
||||
properties: Optional[List[str]] = None,
|
||||
order_by: Optional[List[Tuple[str, str]]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for files using WebDAV SEARCH method (RFC 5323).
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
where_conditions: XML string for where clause conditions
|
||||
properties: List of property names to retrieve (defaults to basic set)
|
||||
order_by: List of (property, direction) tuples for sorting, e.g. [("getlastmodified", "descending")]
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of file/directory dictionaries with requested properties
|
||||
"""
|
||||
# Default properties if not specified
|
||||
if properties is None:
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
]
|
||||
|
||||
# Build the SEARCH request XML
|
||||
search_body = self._build_search_xml(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
order_by=order_by,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# The SEARCH endpoint is at the dav root
|
||||
search_path = "/remote.php/dav/"
|
||||
|
||||
headers = {"Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
logger.debug(f"Searching files in scope: {scope}")
|
||||
|
||||
try:
|
||||
response = await self._make_request(
|
||||
"SEARCH", search_path, content=search_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
results = self._parse_search_response(response.content, scope)
|
||||
|
||||
logger.debug(f"Search returned {len(results)} results")
|
||||
return results
|
||||
|
||||
except HTTPStatusError as e:
|
||||
logger.error(f"HTTP error during search: {e}")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during search: {e}")
|
||||
raise e
|
||||
|
||||
def _build_search_xml(
|
||||
self,
|
||||
scope: str,
|
||||
where_conditions: Optional[str],
|
||||
properties: List[str],
|
||||
order_by: Optional[List[Tuple[str, str]]],
|
||||
limit: Optional[int],
|
||||
) -> str:
|
||||
"""Build the XML body for a SEARCH request."""
|
||||
# Construct the scope path
|
||||
username = self.username
|
||||
scope_path = f"/files/{username}"
|
||||
if scope:
|
||||
scope_path = f"{scope_path}/{scope.lstrip('/')}"
|
||||
|
||||
# Build property list
|
||||
prop_xml = "\n".join([self._property_to_xml(prop) for prop in properties])
|
||||
|
||||
# Build where clause
|
||||
where_xml = where_conditions if where_conditions else ""
|
||||
|
||||
# Build order by clause
|
||||
orderby_xml = ""
|
||||
if order_by:
|
||||
order_elements = []
|
||||
for prop, direction in order_by:
|
||||
prop_element = self._property_to_xml(prop)
|
||||
dir_element = (
|
||||
"<d:ascending/>"
|
||||
if direction.lower() == "ascending"
|
||||
else "<d:descending/>"
|
||||
)
|
||||
order_elements.append(f"<d:order>{prop_element}{dir_element}</d:order>")
|
||||
orderby_xml = "\n".join(order_elements)
|
||||
else:
|
||||
orderby_xml = ""
|
||||
|
||||
# Build limit clause
|
||||
limit_xml = (
|
||||
f"<d:limit><d:nresults>{limit}</d:nresults></d:limit>" if limit else ""
|
||||
)
|
||||
|
||||
# Construct the full SEARCH XML
|
||||
search_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:basicsearch>
|
||||
<d:select>
|
||||
<d:prop>
|
||||
{prop_xml}
|
||||
</d:prop>
|
||||
</d:select>
|
||||
<d:from>
|
||||
<d:scope>
|
||||
<d:href>{scope_path}</d:href>
|
||||
<d:depth>infinity</d:depth>
|
||||
</d:scope>
|
||||
</d:from>
|
||||
<d:where>
|
||||
{where_xml}
|
||||
</d:where>
|
||||
<d:orderby>
|
||||
{orderby_xml}
|
||||
</d:orderby>
|
||||
{limit_xml}
|
||||
</d:basicsearch>
|
||||
</d:searchrequest>"""
|
||||
|
||||
return search_xml
|
||||
|
||||
def _property_to_xml(self, prop: str) -> str:
|
||||
"""Convert a property name to its XML element."""
|
||||
# Handle properties with namespace prefixes
|
||||
if prop.startswith("{"):
|
||||
# Already a full namespace
|
||||
namespace_end = prop.index("}")
|
||||
namespace = prop[1:namespace_end]
|
||||
local_name = prop[namespace_end + 1 :]
|
||||
|
||||
# Map namespace URIs to prefixes
|
||||
ns_map = {
|
||||
"DAV:": "d",
|
||||
"http://owncloud.org/ns": "oc",
|
||||
"http://nextcloud.org/ns": "nc",
|
||||
}
|
||||
|
||||
prefix = ns_map.get(namespace, "d")
|
||||
return f"<{prefix}:{local_name}/>"
|
||||
else:
|
||||
# Guess namespace based on common properties
|
||||
if prop in [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"quota-available-bytes",
|
||||
"quota-used-bytes",
|
||||
]:
|
||||
return f"<d:{prop}/>"
|
||||
elif prop in [
|
||||
"fileid",
|
||||
"size",
|
||||
"permissions",
|
||||
"favorite",
|
||||
"tags",
|
||||
"owner-id",
|
||||
"owner-display-name",
|
||||
"share-types",
|
||||
"checksums",
|
||||
"comments-count",
|
||||
"comments-unread",
|
||||
]:
|
||||
return f"<oc:{prop}/>"
|
||||
else:
|
||||
# Assume nc namespace for newer properties
|
||||
return f"<nc:{prop}/>"
|
||||
|
||||
def _parse_search_response(
|
||||
self, xml_content: bytes, scope: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse the XML response from a SEARCH request."""
|
||||
root = ET.fromstring(xml_content)
|
||||
items = []
|
||||
|
||||
# Process each response element
|
||||
responses = root.findall(".//{DAV:}response")
|
||||
|
||||
for response_elem in responses:
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
continue
|
||||
|
||||
# Extract file/directory path from href
|
||||
href_text = href.text or ""
|
||||
# Remove the /remote.php/dav/files/username/ prefix to get relative path
|
||||
path_parts = href_text.split("/files/")
|
||||
if len(path_parts) > 1:
|
||||
# Get the path after username
|
||||
path_after_user = "/".join(path_parts[1].split("/")[1:])
|
||||
relative_path = path_after_user.rstrip("/")
|
||||
else:
|
||||
relative_path = href_text.rstrip("/").split("/")[-1]
|
||||
|
||||
# Get properties
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Build item dictionary
|
||||
item = {"path": relative_path, "href": href_text}
|
||||
|
||||
# Extract all properties
|
||||
for child in prop:
|
||||
tag = child.tag
|
||||
value = child.text
|
||||
|
||||
# Remove namespace from tag
|
||||
if "}" in tag:
|
||||
tag = tag.split("}", 1)[1]
|
||||
|
||||
# Handle special properties
|
||||
if tag == "resourcetype":
|
||||
item["is_directory"] = child.find(".//{DAV:}collection") is not None
|
||||
elif tag == "getcontentlength":
|
||||
item["size"] = int(value) if value else 0
|
||||
elif tag == "displayname":
|
||||
item["name"] = value
|
||||
elif tag == "getcontenttype":
|
||||
item["content_type"] = value
|
||||
elif tag == "getlastmodified":
|
||||
item["last_modified"] = value
|
||||
elif tag == "getetag":
|
||||
item["etag"] = value.strip('"') if value else None
|
||||
elif tag == "fileid":
|
||||
item["file_id"] = int(value) if value else None
|
||||
elif tag == "favorite":
|
||||
item["is_favorite"] = value == "1"
|
||||
elif tag == "permissions":
|
||||
item["permissions"] = value
|
||||
elif tag == "size":
|
||||
# oc:size includes folder sizes
|
||||
item["total_size"] = int(value) if value else 0
|
||||
else:
|
||||
# Store other properties as-is
|
||||
item[tag] = value
|
||||
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
async def find_by_name(
|
||||
self, pattern: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by name pattern using LIKE matching.
|
||||
|
||||
Args:
|
||||
pattern: Name pattern to search for (supports % wildcard)
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files/directories
|
||||
|
||||
Examples:
|
||||
# Find all .txt files
|
||||
results = await find_by_name("%.txt")
|
||||
|
||||
# Find files starting with "report"
|
||||
results = await find_by_name("report%")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>{pattern}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def find_by_type(
|
||||
self, mime_type: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by MIME type.
|
||||
|
||||
Args:
|
||||
mime_type: MIME type to search for (supports % wildcard, e.g., "image/%")
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of matching files
|
||||
|
||||
Examples:
|
||||
# Find all images
|
||||
results = await find_by_type("image/%")
|
||||
|
||||
# Find all PDFs
|
||||
results = await find_by_type("application/pdf")
|
||||
"""
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:getcontenttype/>
|
||||
</d:prop>
|
||||
<d:literal>{mime_type}</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope, where_conditions=where_conditions, limit=limit
|
||||
)
|
||||
|
||||
async def list_favorites(
|
||||
self, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List all favorite files.
|
||||
|
||||
Args:
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of favorite files/directories
|
||||
|
||||
Examples:
|
||||
# List all favorites
|
||||
results = await list_favorites()
|
||||
|
||||
# List favorites in a specific folder
|
||||
results = await list_favorites(scope="Documents")
|
||||
"""
|
||||
# Use REPORT method for favorites as it's more efficient
|
||||
# But we can also use SEARCH as fallback
|
||||
where_conditions = """
|
||||
<d:eq>
|
||||
<d:prop>
|
||||
<oc:favorite/>
|
||||
</d:prop>
|
||||
<d:literal>1</d:literal>
|
||||
</d:eq>
|
||||
"""
|
||||
|
||||
# Request favorite property
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"favorite",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -65,11 +65,14 @@ from .tables import (
|
||||
|
||||
# WebDAV models
|
||||
from .webdav import (
|
||||
CopyResourceResponse,
|
||||
CreateDirectoryResponse,
|
||||
DeleteResourceResponse,
|
||||
DirectoryListing,
|
||||
FileInfo,
|
||||
MoveResourceResponse,
|
||||
ReadFileResponse,
|
||||
SearchFilesResponse,
|
||||
WriteFileResponse,
|
||||
)
|
||||
|
||||
@@ -133,4 +136,7 @@ __all__ = [
|
||||
"WriteFileResponse",
|
||||
"CreateDirectoryResponse",
|
||||
"DeleteResourceResponse",
|
||||
"MoveResourceResponse",
|
||||
"CopyResourceResponse",
|
||||
"SearchFilesResponse",
|
||||
]
|
||||
|
||||
@@ -180,3 +180,71 @@ class ManageCalendarResponse(BaseResponse):
|
||||
None, description="List of calendars (for list action)"
|
||||
)
|
||||
message: str = Field(description="Success message")
|
||||
|
||||
|
||||
# ============= Todo/Task Models =============
|
||||
|
||||
|
||||
class Todo(BaseModel):
|
||||
"""Model for a CalDAV todo/task (VTODO)."""
|
||||
|
||||
uid: str = Field(description="Todo UID")
|
||||
summary: str = Field(description="Todo summary/title")
|
||||
description: str = Field(default="", description="Todo description")
|
||||
status: str = Field(
|
||||
default="NEEDS-ACTION",
|
||||
description="Todo status: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED",
|
||||
)
|
||||
priority: int = Field(
|
||||
default=0, description="Todo priority (0=undefined, 1=highest, 9=lowest)"
|
||||
)
|
||||
percent_complete: int = Field(default=0, description="Percentage complete (0-100)")
|
||||
due: Optional[str] = Field(None, description="Due date/time (ISO format)")
|
||||
dtstart: Optional[str] = Field(None, description="Start date/time (ISO format)")
|
||||
completed: Optional[str] = Field(
|
||||
None, description="Completion timestamp (ISO format)"
|
||||
)
|
||||
categories: str = Field(default="", description="Comma-separated categories")
|
||||
href: str = Field(default="", description="CalDAV href")
|
||||
etag: str = Field(default="", description="ETag for versioning")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this todo"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this todo"
|
||||
)
|
||||
|
||||
|
||||
class ListTodosResponse(BaseResponse):
|
||||
"""Response model for listing todos."""
|
||||
|
||||
todos: List[Todo] = Field(description="List of todos/tasks")
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar name (if filtered to one calendar)"
|
||||
)
|
||||
total_count: int = Field(description="Total number of todos found")
|
||||
|
||||
|
||||
class CreateTodoResponse(BaseResponse):
|
||||
"""Response model for todo creation."""
|
||||
|
||||
todo: Todo = Field(description="The created todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was created in"
|
||||
)
|
||||
|
||||
|
||||
class UpdateTodoResponse(BaseResponse):
|
||||
"""Response model for todo updates."""
|
||||
|
||||
todo: Todo = Field(description="The updated todo")
|
||||
calendar_name: str = Field(description="Name of the calendar the todo belongs to")
|
||||
|
||||
|
||||
class DeleteTodoResponse(StatusResponse):
|
||||
"""Response model for todo deletion."""
|
||||
|
||||
deleted_uid: str = Field(description="UID of the deleted todo")
|
||||
calendar_name: str = Field(
|
||||
description="Name of the calendar the todo was deleted from"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Pydantic models for Cookbook app responses."""
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
|
||||
class Nutrition(BaseModel):
|
||||
"""Nutrition information following schema.org/NutritionInformation."""
|
||||
|
||||
type: str = Field(
|
||||
default="NutritionInformation",
|
||||
alias="@type",
|
||||
description="Schema.org object type",
|
||||
)
|
||||
calories: Optional[str] = Field(None, description="Calories (e.g., '650 kcal')")
|
||||
carbohydrateContent: Optional[str] = Field(
|
||||
None, description="Carbohydrates (e.g., '300 g')"
|
||||
)
|
||||
cholesterolContent: Optional[str] = Field(
|
||||
None, description="Cholesterol (e.g., '10 g')"
|
||||
)
|
||||
fatContent: Optional[str] = Field(None, description="Fat (e.g., '45 g')")
|
||||
fiberContent: Optional[str] = Field(None, description="Fiber (e.g., '50 g')")
|
||||
proteinContent: Optional[str] = Field(None, description="Protein (e.g., '80 g')")
|
||||
saturatedFatContent: Optional[str] = Field(
|
||||
None, description="Saturated fat (e.g., '5 g')"
|
||||
)
|
||||
servingSize: Optional[str] = Field(
|
||||
None, description="Serving size description (e.g., 'One plate')"
|
||||
)
|
||||
sodiumContent: Optional[str] = Field(None, description="Sodium (e.g., '10 mg')")
|
||||
sugarContent: Optional[str] = Field(None, description="Sugar (e.g., '5 g')")
|
||||
transFatContent: Optional[str] = Field(None, description="Trans fat (e.g., '10 g')")
|
||||
unsaturatedFatContent: Optional[str] = Field(
|
||||
None, description="Unsaturated fat (e.g., '40 g')"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class RecipeStub(BaseModel):
|
||||
"""Stub of a recipe with basic information."""
|
||||
|
||||
id: str = Field(description="Recipe ID as string")
|
||||
recipe_id: int = Field(description="Recipe ID as integer (deprecated)")
|
||||
name: str = Field(description="Recipe name")
|
||||
keywords: Optional[str] = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: str = Field(description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
imageUrl: str = Field(default="", description="URL of the recipe image")
|
||||
imagePlaceholderUrl: str = Field(default="", description="URL of placeholder image")
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
"""Full recipe following schema.org/Recipe specification."""
|
||||
|
||||
type: str = Field(default="Recipe", alias="@type", description="Schema.org type")
|
||||
id: Optional[str] = Field(None, description="Recipe ID")
|
||||
name: str = Field(description="Recipe name")
|
||||
description: str = Field(default="", description="Recipe description")
|
||||
url: str = Field(default="", description="Original recipe URL")
|
||||
image: str = Field(default="", description="URL of original recipe image")
|
||||
imageUrl: Optional[str] = Field(
|
||||
None, description="URL of the recipe image in Nextcloud"
|
||||
)
|
||||
imagePlaceholderUrl: Optional[str] = Field(
|
||||
None, description="URL of placeholder image"
|
||||
)
|
||||
keywords: str = Field(default="", description="Comma-separated keywords")
|
||||
dateCreated: Optional[str] = Field(None, description="Creation date (ISO8601)")
|
||||
dateModified: Optional[str] = Field(
|
||||
None, description="Last modified date (ISO8601)"
|
||||
)
|
||||
prepTime: Optional[str] = Field(None, description="Preparation time (ISO8601)")
|
||||
cookTime: Optional[str] = Field(None, description="Cooking time (ISO8601)")
|
||||
totalTime: Optional[str] = Field(None, description="Total time (ISO8601)")
|
||||
recipeYield: Union[int, str] = Field(default=1, description="Number of servings")
|
||||
recipeCategory: str = Field(default="", description="Recipe category")
|
||||
tool: List[str] = Field(default_factory=list, description="Required tools")
|
||||
recipeIngredient: List[str] = Field(
|
||||
default_factory=list, description="List of ingredients"
|
||||
)
|
||||
recipeInstructions: List[str] = Field(
|
||||
default_factory=list, description="Cooking instructions"
|
||||
)
|
||||
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "allow" # Allow additional schema.org fields
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
"""A recipe category."""
|
||||
|
||||
name: str = Field(description="Category name")
|
||||
recipe_count: int = Field(description="Number of recipes in category")
|
||||
|
||||
|
||||
class Keyword(BaseModel):
|
||||
"""A recipe keyword/tag."""
|
||||
|
||||
name: str = Field(description="Keyword name")
|
||||
recipe_count: int = Field(description="Number of recipes with this keyword")
|
||||
|
||||
|
||||
class VisibleInfoBlocks(BaseModel):
|
||||
"""Configuration for visible information blocks in the UI."""
|
||||
|
||||
preparation_time: Optional[bool] = Field(
|
||||
None, alias="preparation-time", description="Show preparation time"
|
||||
)
|
||||
cooking_time: Optional[bool] = Field(
|
||||
None, alias="cooking-time", description="Show cooking time"
|
||||
)
|
||||
total_time: Optional[bool] = Field(
|
||||
None, alias="total-time", description="Show total time"
|
||||
)
|
||||
nutrition_information: Optional[bool] = Field(
|
||||
None, alias="nutrition-information", description="Show nutrition info"
|
||||
)
|
||||
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class CookbookConfig(BaseModel):
|
||||
"""Cookbook app configuration."""
|
||||
|
||||
folder: Optional[str] = Field(None, description="Recipe folder path")
|
||||
update_interval: Optional[int] = Field(
|
||||
None, description="Auto-rescan interval in minutes"
|
||||
)
|
||||
print_image: Optional[bool] = Field(None, description="Print images with recipes")
|
||||
visibleInfoBlocks: Optional[VisibleInfoBlocks] = Field(
|
||||
None, description="Visible info blocks configuration"
|
||||
)
|
||||
|
||||
|
||||
class APIVersion(BaseModel):
|
||||
"""API version information."""
|
||||
|
||||
epoch: int = Field(description="API epoch")
|
||||
major: int = Field(description="Major version")
|
||||
minor: int = Field(description="Minor version")
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
"""Version information for Cookbook app and API."""
|
||||
|
||||
cookbook_version: List[int] = Field(description="Cookbook app version")
|
||||
api_version: APIVersion = Field(description="API version")
|
||||
|
||||
|
||||
# Response models for MCP tools
|
||||
|
||||
|
||||
class ImportRecipeResponse(BaseResponse):
|
||||
"""Response model for recipe import."""
|
||||
|
||||
recipe: Recipe = Field(description="The imported recipe")
|
||||
recipe_id: str = Field(description="ID of the imported recipe")
|
||||
|
||||
|
||||
class CreateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe creation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UpdateRecipeResponse(IdResponse):
|
||||
"""Response model for recipe update."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DeleteRecipeResponse(StatusResponse):
|
||||
"""Response model for recipe deletion."""
|
||||
|
||||
deleted_id: int = Field(description="ID of deleted recipe")
|
||||
|
||||
|
||||
class ListRecipesResponse(BaseResponse):
|
||||
"""Response model for listing recipes."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="List of recipe stubs")
|
||||
total_count: int = Field(description="Total number of recipes")
|
||||
|
||||
|
||||
class SearchRecipesResponse(BaseResponse):
|
||||
"""Response model for recipe search."""
|
||||
|
||||
recipes: List[RecipeStub] = Field(description="Matching recipes")
|
||||
query: str = Field(description="Search query used")
|
||||
total_found: int = Field(description="Number of recipes found")
|
||||
|
||||
|
||||
class ListCategoriesResponse(BaseResponse):
|
||||
"""Response model for listing categories."""
|
||||
|
||||
categories: List[Category] = Field(description="List of categories")
|
||||
|
||||
|
||||
class ListKeywordsResponse(BaseResponse):
|
||||
"""Response model for listing keywords."""
|
||||
|
||||
keywords: List[Keyword] = Field(description="List of keywords")
|
||||
|
||||
|
||||
class ReindexResponse(StatusResponse):
|
||||
"""Response model for reindex operation."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,41 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""Model for creating a new user."""
|
||||
|
||||
userid: str
|
||||
password: Optional[str] = None
|
||||
displayName: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
groups: Optional[List[str]] = Field(default_factory=list)
|
||||
subadmin: Optional[List[str]] = Field(default_factory=list)
|
||||
quota: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class UserDetails(BaseModel):
|
||||
"""Model for retrieving detailed user information."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
enabled: bool
|
||||
id: str
|
||||
quota: Union[str, Dict[str, Any]] # Can be string or quota object
|
||||
email: Optional[str] = None # Can be null
|
||||
displayname: str = Field(
|
||||
alias="display-name"
|
||||
) # Handle both displayname and display-name
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
twitter: Optional[str] = None
|
||||
groups: Optional[List[str]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Group(BaseModel):
|
||||
"""Model for a user group."""
|
||||
|
||||
id: str
|
||||
@@ -22,6 +22,8 @@ class FileInfo(BaseModel):
|
||||
None, description="Last modification time (ISO format)"
|
||||
)
|
||||
etag: Optional[str] = Field(None, description="ETag for versioning")
|
||||
file_id: Optional[int] = Field(None, description="Nextcloud file ID")
|
||||
is_favorite: Optional[bool] = Field(None, description="Whether file is favorited")
|
||||
|
||||
@property
|
||||
def last_modified_datetime(self) -> Optional[datetime]:
|
||||
@@ -106,3 +108,14 @@ class CopyResourceResponse(StatusResponse):
|
||||
overwrite: bool = Field(
|
||||
description="Whether the destination was overwritten if it existed"
|
||||
)
|
||||
|
||||
|
||||
class SearchFilesResponse(BaseResponse):
|
||||
"""Response model for WebDAV search operations."""
|
||||
|
||||
results: List[FileInfo] = Field(description="Search results")
|
||||
total_found: int = Field(description="Total number of files found")
|
||||
scope: str = Field(description="The scope/path that was searched")
|
||||
filters_applied: Optional[dict] = Field(
|
||||
None, description="Filters that were applied to the search"
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
from .calendar import configure_calendar_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
|
||||
__all__ = [
|
||||
"configure_calendar_tools",
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_sharing_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_webdav_tools",
|
||||
]
|
||||
|
||||
@@ -4,8 +4,14 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@require_scopes("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))
|
||||
|
||||
@@ -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 = ""
|
||||
):
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.cookbook import (
|
||||
Category,
|
||||
CookbookConfig,
|
||||
CreateRecipeResponse,
|
||||
DeleteRecipeResponse,
|
||||
ImportRecipeResponse,
|
||||
Keyword,
|
||||
ListCategoriesResponse,
|
||||
ListKeywordsResponse,
|
||||
ListRecipesResponse,
|
||||
Recipe,
|
||||
RecipeStub,
|
||||
ReindexResponse,
|
||||
SearchRecipesResponse,
|
||||
UpdateRecipeResponse,
|
||||
Version,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_cookbook_tools(mcp: FastMCP):
|
||||
@mcp.resource("cookbook://version")
|
||||
async def cookbook_get_version():
|
||||
"""Get the Cookbook app and API version"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
version_data = await client.cookbook.get_version()
|
||||
return Version(**version_data)
|
||||
|
||||
@mcp.resource("cookbook://config")
|
||||
async def cookbook_get_config():
|
||||
"""Get the Cookbook app configuration"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
config_data = await client.cookbook.get_config()
|
||||
return CookbookConfig(**config_data)
|
||||
|
||||
@mcp.resource("nc://Cookbook/{recipe_id}")
|
||||
async def nc_cookbook_get_recipe_resource(recipe_id: int):
|
||||
"""Get a recipe by ID using resource URI"""
|
||||
ctx: Context = mcp.get_context()
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
This extracts recipe data from websites that use schema.org Recipe markup.
|
||||
Many popular recipe sites support this standard."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipe_data = await client.cookbook.import_recipe(url)
|
||||
recipe = Recipe(**recipe_data)
|
||||
return ImportRecipeResponse(
|
||||
recipe=recipe,
|
||||
recipe_id=recipe.id or "unknown",
|
||||
)
|
||||
except RequestError as e:
|
||||
# RequestError can have empty str() - get details from exception attributes
|
||||
error_detail = (
|
||||
str(e)
|
||||
or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}"
|
||||
)
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error importing recipe from {url}: {error_detail}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 400:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Invalid URL or missing 'url' field: {url}",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 409:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="A recipe with this name already exists. Import aborted.",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to import recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to import recipe from {url}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.list_recipes()
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list recipes: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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)
|
||||
try:
|
||||
recipe_data = await client.cookbook.get_recipe(recipe_id)
|
||||
return Recipe(**recipe_data)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Access denied to recipe {recipe_id}")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_create_recipe(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
ingredients: list[str] | None = None,
|
||||
instructions: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
prep_time: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
total_time: str | None = None,
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
) -> CreateRecipeResponse:
|
||||
"""Create a new recipe.
|
||||
|
||||
Required: name
|
||||
Optional: All other recipe fields following schema.org/Recipe format.
|
||||
|
||||
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
|
||||
client = get_client(ctx)
|
||||
|
||||
recipe_data = {"name": name}
|
||||
if description:
|
||||
recipe_data["description"] = description
|
||||
if ingredients:
|
||||
recipe_data["recipeIngredient"] = ingredients
|
||||
if instructions:
|
||||
recipe_data["recipeInstructions"] = instructions
|
||||
if url:
|
||||
recipe_data["url"] = url
|
||||
if prep_time:
|
||||
recipe_data["prepTime"] = prep_time
|
||||
if cook_time:
|
||||
recipe_data["cookTime"] = cook_time
|
||||
if total_time:
|
||||
recipe_data["totalTime"] = total_time
|
||||
if recipe_yield:
|
||||
recipe_data["recipeYield"] = recipe_yield
|
||||
if category:
|
||||
recipe_data["recipeCategory"] = category
|
||||
if keywords:
|
||||
recipe_data["keywords"] = keywords
|
||||
|
||||
try:
|
||||
recipe_id = await client.cookbook.create_recipe(recipe_data)
|
||||
return CreateRecipeResponse(id=recipe_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"A recipe with name '{name}' already exists",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 422:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Recipe name is required and cannot be empty",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to create recipes",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to create recipe: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_update_recipe(
|
||||
recipe_id: int,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
ingredients: list[str] | None = None,
|
||||
instructions: list[str] | None = None,
|
||||
url: str | None = None,
|
||||
prep_time: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
total_time: str | None = None,
|
||||
recipe_yield: int | None = None,
|
||||
category: str | None = None,
|
||||
keywords: str | None = None,
|
||||
ctx: Context = None,
|
||||
) -> UpdateRecipeResponse:
|
||||
"""Update an existing recipe.
|
||||
|
||||
Provide only the fields you want to update. Unspecified fields remain unchanged."""
|
||||
client = get_client(ctx)
|
||||
|
||||
# First get the current recipe
|
||||
try:
|
||||
current_recipe = await client.cookbook.get_recipe(recipe_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to fetch recipe {recipe_id}: {e.response.reason_phrase}",
|
||||
)
|
||||
)
|
||||
|
||||
# Update only specified fields
|
||||
recipe_data = current_recipe.copy()
|
||||
if name is not None:
|
||||
recipe_data["name"] = name
|
||||
if description is not None:
|
||||
recipe_data["description"] = description
|
||||
if ingredients is not None:
|
||||
recipe_data["recipeIngredient"] = ingredients
|
||||
if instructions is not None:
|
||||
recipe_data["recipeInstructions"] = instructions
|
||||
if url is not None:
|
||||
recipe_data["url"] = url
|
||||
if prep_time is not None:
|
||||
recipe_data["prepTime"] = prep_time
|
||||
if cook_time is not None:
|
||||
recipe_data["cookTime"] = cook_time
|
||||
if total_time is not None:
|
||||
recipe_data["totalTime"] = total_time
|
||||
if recipe_yield is not None:
|
||||
recipe_data["recipeYield"] = recipe_yield
|
||||
if category is not None:
|
||||
recipe_data["recipeCategory"] = category
|
||||
if keywords is not None:
|
||||
recipe_data["keywords"] = keywords
|
||||
|
||||
try:
|
||||
updated_id = await client.cookbook.update_recipe(recipe_id, recipe_data)
|
||||
return UpdateRecipeResponse(id=updated_id)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 422:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Recipe name is required and cannot be empty",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Access denied: insufficient permissions to update recipe {recipe_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to update recipe {recipe_id}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_delete_recipe(
|
||||
recipe_id: int, ctx: Context
|
||||
) -> DeleteRecipeResponse:
|
||||
"""Delete a recipe permanently"""
|
||||
logger.info("Deleting recipe %s", recipe_id)
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.delete_recipe(recipe_id)
|
||||
return DeleteRecipeResponse(
|
||||
status_code=200,
|
||||
message=message,
|
||||
deleted_id=recipe_id,
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Recipe {recipe_id} not found")
|
||||
)
|
||||
elif e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Access denied: insufficient permissions to delete recipe {recipe_id}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to delete recipe {recipe_id}: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_search_recipes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
"""Search for recipes by keywords, tags, and categories"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.search_recipes(query)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return SearchRecipesResponse(
|
||||
recipes=recipes, query=query, total_found=len(recipes)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to search recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Search failed: server error",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Search failed: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
"""Get all known categories.
|
||||
|
||||
Note: A category name of '*' indicates recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
categories_data = await client.cookbook.list_categories()
|
||||
categories = [Category(**c) for c in categories_data]
|
||||
return ListCategoriesResponse(categories=categories)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list categories",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list categories: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
category: str, ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes in a specific category.
|
||||
|
||||
Use '_' as the category name to get recipes with no category."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_in_category(category)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to access recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Could not find category '{category}'",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get recipes in category: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
keywords_data = await client.cookbook.list_keywords()
|
||||
keywords = [Keyword(**k) for k in keywords_data]
|
||||
return ListKeywordsResponse(keywords=keywords)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to list keywords",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to list keywords: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
"""Get all recipes that have specific keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
|
||||
recipes = [RecipeStub(**r) for r in recipes_data]
|
||||
return ListRecipesResponse(recipes=recipes, total_count=len(recipes))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to access recipes",
|
||||
)
|
||||
)
|
||||
elif e.response.status_code == 500:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Failed to get recipes with keywords: server error",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to get recipes with keywords: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_set_config(
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
print_image: bool | None = None,
|
||||
ctx: Context = None,
|
||||
) -> ReindexResponse:
|
||||
"""Set Cookbook app configuration.
|
||||
|
||||
Args:
|
||||
folder: Recipe folder path in user's files
|
||||
update_interval: Automatic rescan interval in minutes
|
||||
print_image: Whether to print images with recipes"""
|
||||
client = get_client(ctx)
|
||||
|
||||
config_data = {}
|
||||
if folder is not None:
|
||||
config_data["folder"] = folder
|
||||
if update_interval is not None:
|
||||
config_data["update_interval"] = update_interval
|
||||
if print_image is not None:
|
||||
config_data["print_image"] = print_image
|
||||
|
||||
try:
|
||||
result = await client.cookbook.set_config(config_data)
|
||||
return ReindexResponse(status_code=200, message=str(result))
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to set configuration",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to set configuration: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
This rebuilds the search index and should be used after manual file changes."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
message = await client.cookbook.reindex()
|
||||
return ReindexResponse(status_code=200, message=message)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Access denied: insufficient permissions to reindex",
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to reindex: server error ({e.response.status_code})",
|
||||
)
|
||||
)
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
CardOperationResponse,
|
||||
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
AppendContentResponse,
|
||||
@@ -61,6 +62,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error retrieving note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -77,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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(
|
||||
@@ -92,6 +101,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return CreateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error creating note: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -118,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
@@ -126,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
"""Update an existing note's title, content, or category (requires 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.
|
||||
@@ -146,6 +160,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return UpdateNoteResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error updating note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -176,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_notes_append_content(
|
||||
note_id: int, content: str, ctx: Context
|
||||
) -> AppendContentResponse:
|
||||
@@ -192,6 +213,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return AppendContentResponse(
|
||||
id=note.id, title=note.title, category=note.category, etag=note.etag
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error appending to note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -218,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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)
|
||||
@@ -238,6 +267,10 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
return SearchNotesResponse(
|
||||
results=results, query=query, total_found=len(results)
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error searching notes: {str(e)}")
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
raise McpError(
|
||||
@@ -259,12 +292,19 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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)
|
||||
return Note(**note_data)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error getting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
@@ -281,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:read")
|
||||
async def nc_notes_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
@@ -295,6 +336,13 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
"mimeType": mime_type,
|
||||
"data": content,
|
||||
}
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}",
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(
|
||||
@@ -319,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("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)
|
||||
@@ -330,6 +379,12 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
message=f"Note {note_id} deleted successfully",
|
||||
deleted_id=note_id,
|
||||
)
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1, message=f"Network error deleting note {note_id}: {str(e)}"
|
||||
)
|
||||
)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found"))
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""MCP tools for Nextcloud file/folder sharing operations."""
|
||||
|
||||
import json
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
|
||||
def configure_sharing_tools(mcp: FastMCP):
|
||||
"""Configure sharing-related MCP tools.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_create(
|
||||
path: str,
|
||||
share_with: str,
|
||||
ctx: Context,
|
||||
share_type: int = 0,
|
||||
permissions: int = 1,
|
||||
) -> str:
|
||||
"""Create a share for a file or folder in Nextcloud.
|
||||
|
||||
Share a file or folder with another user or group. The authenticated user
|
||||
must own the file/folder being shared.
|
||||
|
||||
Args:
|
||||
path: Path to file/folder to share (relative to your files, e.g., "/document.txt")
|
||||
share_with: Username (for user share) or group name (for group share)
|
||||
share_type: Share type - 0 for user (default), 1 for group, 3 for public link
|
||||
permissions: Share permissions (default: 1 for read-only):
|
||||
- 1 = read
|
||||
- 2 = update
|
||||
- 4 = create
|
||||
- 8 = delete
|
||||
- 16 = share
|
||||
- 31 = all permissions
|
||||
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
|
||||
|
||||
Returns:
|
||||
JSON string with share information including share ID
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
share_data = await client.sharing.create_share(
|
||||
path=path,
|
||||
share_with=share_with,
|
||||
share_type=share_type,
|
||||
permissions=permissions,
|
||||
)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
Remove a share that you created. You must be the owner of the share.
|
||||
|
||||
Args:
|
||||
share_id: The ID of the share to delete
|
||||
|
||||
Returns:
|
||||
JSON string confirming deletion
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
await client.sharing.delete_share(share_id)
|
||||
return json.dumps(
|
||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||
"""Get information about a specific share.
|
||||
|
||||
Retrieve details about a share by its ID. You must have access to the share
|
||||
(either as owner or recipient).
|
||||
|
||||
Args:
|
||||
share_id: The ID of the share
|
||||
|
||||
Returns:
|
||||
JSON string with share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
share_data = await client.sharing.get_share(share_id)
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_list(
|
||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||
) -> str:
|
||||
"""List shares created by you or shared with you.
|
||||
|
||||
Args:
|
||||
path: Optional path to filter shares for a specific file/folder
|
||||
shared_with_me: If True, list shares that others shared with you.
|
||||
If False (default), list shares you created.
|
||||
|
||||
Returns:
|
||||
JSON string with list of shares
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
shares = await client.sharing.list_shares(
|
||||
path=path, shared_with_me=shared_with_me
|
||||
)
|
||||
return json.dumps(shares, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("nc:write")
|
||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||
"""Update the permissions of an existing share.
|
||||
|
||||
Modify the permissions for a share you created. You must be the owner.
|
||||
|
||||
Args:
|
||||
share_id: The ID of the share to update
|
||||
permissions: New permissions value:
|
||||
- 1 = read
|
||||
- 2 = update
|
||||
- 4 = create
|
||||
- 8 = delete
|
||||
- 16 = share
|
||||
- 31 = all permissions
|
||||
Common: 1 (read-only), 3 (read+update), 15 (read+update+create+delete)
|
||||
|
||||
Returns:
|
||||
JSON string with updated share information
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
share_data = await client.sharing.update_share(
|
||||
share_id=share_id, permissions=permissions
|
||||
)
|
||||
return json.dumps(share_data, indent=2)
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
@require_scopes("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)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
+53
-13
@@ -1,29 +1,50 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.13.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.17,<1.18)",
|
||||
"mcp[cli] (>=1.18,<1.19)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=11.2.1,<12.0.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0", # 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 = "INFO"
|
||||
log_level = "INFO"
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Script to automatically add @require_scopes decorators to MCP tools.
|
||||
|
||||
This script parses server module files and adds appropriate scope decorators
|
||||
based on the operation type (read vs write).
|
||||
|
||||
Usage:
|
||||
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
# Operation patterns for classification
|
||||
READ_PATTERNS = [
|
||||
r".*_get_.*",
|
||||
r".*_get$",
|
||||
r".*_list_.*",
|
||||
r".*_list$",
|
||||
r".*_search_.*",
|
||||
r".*_search$",
|
||||
r".*_read_.*",
|
||||
r".*_read$",
|
||||
r".*_find_.*",
|
||||
r".*_find$",
|
||||
r".*_fetch_.*",
|
||||
r".*_fetch$",
|
||||
r".*_retrieve_.*",
|
||||
r".*_retrieve$",
|
||||
]
|
||||
|
||||
WRITE_PATTERNS = [
|
||||
r".*_create_.*",
|
||||
r".*_create$",
|
||||
r".*_update_.*",
|
||||
r".*_update$",
|
||||
r".*_delete_.*",
|
||||
r".*_delete$",
|
||||
r".*_append_.*",
|
||||
r".*_append$",
|
||||
r".*_modify_.*",
|
||||
r".*_modify$",
|
||||
r".*_set_.*",
|
||||
r".*_set$",
|
||||
r".*_add_.*",
|
||||
r".*_add$",
|
||||
r".*_remove_.*",
|
||||
r".*_remove$",
|
||||
r".*_edit_.*",
|
||||
r".*_edit$",
|
||||
r".*_move_.*",
|
||||
r".*_move$",
|
||||
r".*_copy_.*",
|
||||
r".*_copy$",
|
||||
r".*_upload_.*",
|
||||
r".*_upload$",
|
||||
r".*_download_.*",
|
||||
r".*_download$",
|
||||
r".*_share_.*",
|
||||
r".*_share$",
|
||||
r".*_unshare_.*",
|
||||
r".*_unshare$",
|
||||
r".*_bulk_.*", # Bulk operations are typically writes
|
||||
]
|
||||
|
||||
|
||||
def classify_operation(func_name: str) -> str | None:
|
||||
"""Classify a function as read or write operation.
|
||||
|
||||
Args:
|
||||
func_name: Function name to classify
|
||||
|
||||
Returns:
|
||||
"nc:read", "nc:write", or None if cannot classify
|
||||
"""
|
||||
# Check write patterns first (more specific)
|
||||
for pattern in WRITE_PATTERNS:
|
||||
if re.match(pattern, func_name):
|
||||
return "nc:write"
|
||||
|
||||
# Check read patterns
|
||||
for pattern in READ_PATTERNS:
|
||||
if re.match(pattern, func_name):
|
||||
return "nc:read"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
|
||||
"""Check if function already has @require_scopes decorator."""
|
||||
for decorator in decorators:
|
||||
if isinstance(decorator, ast.Call):
|
||||
if (
|
||||
isinstance(decorator.func, ast.Name)
|
||||
and decorator.func.id == "require_scopes"
|
||||
):
|
||||
return True
|
||||
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
|
||||
"""Check if function has @mcp.tool() decorator."""
|
||||
for decorator in decorators:
|
||||
if isinstance(decorator, ast.Call):
|
||||
if isinstance(decorator.func, ast.Attribute):
|
||||
if decorator.func.attr == "tool":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_tools_needing_decorators(
|
||||
file_path: Path, verbose: bool = False
|
||||
) -> List[Tuple[str, int, str]]:
|
||||
"""Find all tools that need scope decorators.
|
||||
|
||||
Returns:
|
||||
List of (function_name, line_number, required_scope)
|
||||
"""
|
||||
with open(file_path) as f:
|
||||
content = f.read()
|
||||
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
print(f" ⚠️ Syntax error in {file_path}: {e}")
|
||||
return []
|
||||
|
||||
tools_to_update = []
|
||||
total_functions = 0
|
||||
mcp_tools = 0
|
||||
already_has_scope = 0
|
||||
cannot_classify = 0
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef):
|
||||
total_functions += 1
|
||||
|
||||
if verbose and node.decorator_list:
|
||||
decorators_str = [
|
||||
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
|
||||
for d in node.decorator_list
|
||||
]
|
||||
print(f" Function {node.name} has decorators: {decorators_str}")
|
||||
|
||||
# Check if it's an MCP tool
|
||||
if not has_mcp_tool_decorator(node.decorator_list):
|
||||
continue
|
||||
|
||||
mcp_tools += 1
|
||||
|
||||
# Check if it already has scope decorator
|
||||
if has_scope_decorator(node.decorator_list):
|
||||
already_has_scope += 1
|
||||
continue
|
||||
|
||||
# Classify operation
|
||||
scope = classify_operation(node.name)
|
||||
if scope:
|
||||
tools_to_update.append((node.name, node.lineno, scope))
|
||||
else:
|
||||
cannot_classify += 1
|
||||
if verbose:
|
||||
print(f" ⚠️ Cannot classify: {node.name}")
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
|
||||
)
|
||||
|
||||
return tools_to_update
|
||||
|
||||
|
||||
def add_decorator_to_file(
|
||||
file_path: Path, dry_run: bool = False, verbose: bool = False
|
||||
) -> int:
|
||||
"""Add @require_scopes decorators to tools in a file.
|
||||
|
||||
Returns:
|
||||
Number of decorators added
|
||||
"""
|
||||
tools = find_tools_needing_decorators(file_path, verbose=verbose)
|
||||
|
||||
if not tools:
|
||||
return 0
|
||||
|
||||
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
|
||||
|
||||
with open(file_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Check if require_scopes is already imported
|
||||
has_import = False
|
||||
import_line_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
|
||||
has_import = True
|
||||
break
|
||||
elif "from nextcloud_mcp_server.auth import" in line:
|
||||
import_line_idx = i
|
||||
|
||||
# Add import if needed
|
||||
if not has_import:
|
||||
if import_line_idx is not None:
|
||||
# Add require_scopes to existing import
|
||||
old_line = lines[import_line_idx]
|
||||
if "(" in old_line:
|
||||
# Multi-line import
|
||||
print(
|
||||
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
else:
|
||||
# Single line import - add require_scopes
|
||||
lines[import_line_idx] = (
|
||||
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
|
||||
)
|
||||
print(" ✓ Added require_scopes to import")
|
||||
else:
|
||||
# No auth import exists, add new import
|
||||
# Find first import line
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("from nextcloud_mcp_server"):
|
||||
lines.insert(
|
||||
i, "from nextcloud_mcp_server.auth import require_scopes\n"
|
||||
)
|
||||
print(
|
||||
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
break
|
||||
|
||||
# Add decorators to tools (in reverse order to preserve line numbers)
|
||||
for func_name, line_num, scope in reversed(tools):
|
||||
# Find the @mcp.tool() decorator line
|
||||
for i in range(line_num - 1, max(0, line_num - 10), -1):
|
||||
if "@mcp.tool()" in lines[i]:
|
||||
# Get indentation from @mcp.tool() line
|
||||
indent = len(lines[i]) - len(lines[i].lstrip())
|
||||
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||
lines.insert(i + 1, decorator_line)
|
||||
print(f' ✓ {func_name}:{line_num} → @require_scopes("{scope}")')
|
||||
break
|
||||
|
||||
if not dry_run:
|
||||
with open(file_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
print(" 💾 Saved changes")
|
||||
else:
|
||||
print(" 🔍 DRY RUN - no changes written")
|
||||
|
||||
return len(tools)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add @require_scopes decorators to MCP tools"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without modifying files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
help="Process a single file instead of all server modules",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="Show debug information",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||
|
||||
if args.file:
|
||||
files = [args.file]
|
||||
else:
|
||||
files = sorted(server_dir.glob("*.py"))
|
||||
files = [f for f in files if f.name != "__init__.py"]
|
||||
|
||||
print("🔍 Scanning for tools needing scope decorators...")
|
||||
print(
|
||||
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||
)
|
||||
|
||||
total_added = 0
|
||||
for file_path in files:
|
||||
added = add_decorator_to_file(
|
||||
file_path, dry_run=args.dry_run, verbose=args.verbose
|
||||
)
|
||||
total_added += added
|
||||
|
||||
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||
print(f" Total decorators added: {total_added}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n💡 Run without --dry-run to apply changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simpler script to add @require_scopes decorators using regex.
|
||||
|
||||
This script uses regex patterns to find @mcp.tool() decorators and adds
|
||||
the appropriate @require_scopes decorator based on function name patterns.
|
||||
|
||||
Usage:
|
||||
python scripts/add_scope_decorators_simple.py [--dry-run]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Operation patterns for classification
|
||||
READ_KEYWORDS = [
|
||||
"get",
|
||||
"list",
|
||||
"search",
|
||||
"read",
|
||||
"find",
|
||||
"fetch",
|
||||
"retrieve",
|
||||
"upcoming",
|
||||
]
|
||||
WRITE_KEYWORDS = [
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"append",
|
||||
"modify",
|
||||
"set",
|
||||
"add",
|
||||
"remove",
|
||||
"edit",
|
||||
"move",
|
||||
"copy",
|
||||
"upload",
|
||||
"download",
|
||||
"share",
|
||||
"unshare",
|
||||
"bulk",
|
||||
"manage",
|
||||
"import",
|
||||
"reindex",
|
||||
"archive",
|
||||
"unarchive",
|
||||
"reorder",
|
||||
"assign",
|
||||
"unassign",
|
||||
"insert",
|
||||
"write",
|
||||
]
|
||||
|
||||
|
||||
def classify_function(func_name: str) -> str | None:
|
||||
"""Classify a function name as read or write operation."""
|
||||
func_lower = func_name.lower()
|
||||
|
||||
# Check write keywords first (more specific)
|
||||
for keyword in WRITE_KEYWORDS:
|
||||
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||
return "nc:write"
|
||||
|
||||
# Check read keywords
|
||||
for keyword in READ_KEYWORDS:
|
||||
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
|
||||
return "nc:read"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def process_file(file_path: Path, dry_run: bool = False) -> int:
|
||||
"""Process a single file to add @require_scopes decorators.
|
||||
|
||||
Returns:
|
||||
Number of decorators added
|
||||
"""
|
||||
with open(file_path) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Check if require_scopes is already imported
|
||||
has_import = False
|
||||
import_line_idx = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "from nextcloud_mcp_server.auth import" in line:
|
||||
if "require_scopes" in line:
|
||||
has_import = True
|
||||
else:
|
||||
import_line_idx = i
|
||||
|
||||
modified = False
|
||||
decorators_added = 0
|
||||
|
||||
# Find all @mcp.tool() decorators
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Look for @mcp.tool() decorator
|
||||
if re.match(r"\s*@mcp\.tool\(\)", line):
|
||||
# Check if next line already has @require_scopes
|
||||
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Find the function definition (should be on next line or after other decorators)
|
||||
func_line_idx = i + 1
|
||||
while func_line_idx < len(lines) and not lines[
|
||||
func_line_idx
|
||||
].strip().startswith("async def"):
|
||||
func_line_idx += 1
|
||||
|
||||
if func_line_idx >= len(lines):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Extract function name
|
||||
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
|
||||
if not func_match:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
func_name = func_match.group(1)
|
||||
scope = classify_function(func_name)
|
||||
|
||||
if scope:
|
||||
# Get indentation from @mcp.tool() line
|
||||
indent = len(line) - len(line.lstrip())
|
||||
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
|
||||
|
||||
# Insert after @mcp.tool()
|
||||
lines.insert(i + 1, decorator_line)
|
||||
decorators_added += 1
|
||||
modified = True
|
||||
print(f' ✓ {func_name} → @require_scopes("{scope}")')
|
||||
else:
|
||||
print(f" ⚠️ Cannot classify: {func_name}")
|
||||
|
||||
i += 1
|
||||
|
||||
# Add import if needed and decorators were added
|
||||
if decorators_added > 0 and not has_import:
|
||||
if import_line_idx is not None:
|
||||
# Add to existing import
|
||||
old_line = lines[import_line_idx]
|
||||
if old_line.rstrip().endswith(")"):
|
||||
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
|
||||
else:
|
||||
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
|
||||
print(" ✓ Added require_scopes to existing import")
|
||||
modified = True
|
||||
else:
|
||||
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
|
||||
last_nc_import_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("from nextcloud_mcp_server"):
|
||||
last_nc_import_idx = i
|
||||
|
||||
if last_nc_import_idx is not None:
|
||||
lines.insert(
|
||||
last_nc_import_idx + 1,
|
||||
"from nextcloud_mcp_server.auth import require_scopes\n",
|
||||
)
|
||||
print(
|
||||
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
|
||||
)
|
||||
modified = True
|
||||
else:
|
||||
print(" ⚠️ Could not find place to add require_scopes import")
|
||||
|
||||
# Write changes
|
||||
if modified and not dry_run:
|
||||
with open(file_path, "w") as f:
|
||||
f.writelines(lines)
|
||||
print(f" 💾 Saved changes to {file_path.name}")
|
||||
elif dry_run and decorators_added > 0:
|
||||
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
|
||||
|
||||
return decorators_added
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Add @require_scopes decorators to MCP tools"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without modifying files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
help="Process a single file instead of all server modules",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
|
||||
|
||||
if args.file:
|
||||
files = [args.file]
|
||||
else:
|
||||
files = sorted(server_dir.glob("*.py"))
|
||||
files = [f for f in files if f.name != "__init__.py"]
|
||||
|
||||
print("🔍 Scanning for tools needing scope decorators...")
|
||||
print(
|
||||
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
|
||||
)
|
||||
|
||||
total_added = 0
|
||||
for file_path in files:
|
||||
file_path = file_path.resolve() # Convert to absolute path
|
||||
try:
|
||||
display_path = file_path.relative_to(Path.cwd())
|
||||
except ValueError:
|
||||
display_path = file_path.name
|
||||
print(f"\n📝 {display_path}")
|
||||
added = process_file(file_path, dry_run=args.dry_run)
|
||||
total_added += added
|
||||
|
||||
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
|
||||
print(f" Total decorators added: {total_added}")
|
||||
|
||||
if args.dry_run and total_added > 0:
|
||||
print("\n💡 Run without --dry-run to apply changes")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Shared fixtures for calendar integration tests.
|
||||
|
||||
Note: The temporary_calendar fixture is defined in tests/conftest.py and uses
|
||||
a shared session-scoped calendar to avoid Nextcloud rate limiting issues.
|
||||
This conftest.py exists for any calendar-specific fixtures that might be needed
|
||||
in the future.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -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
|
||||
@@ -0,0 +1,386 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
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
|
||||
|
||||
|
||||
async def test_cookbook_version(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app version."""
|
||||
logger.info("Getting Cookbook app version")
|
||||
version_data = await nc_client.cookbook.get_version()
|
||||
|
||||
assert "cookbook_version" in version_data
|
||||
assert "api_version" in version_data
|
||||
logger.info(f"Cookbook version: {version_data}")
|
||||
|
||||
|
||||
async def test_cookbook_config(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app configuration."""
|
||||
logger.info("Getting Cookbook app configuration")
|
||||
config_data = await nc_client.cookbook.get_config()
|
||||
|
||||
# Config may be empty initially, just verify we can get it
|
||||
assert isinstance(config_data, dict)
|
||||
logger.info(f"Cookbook config: {config_data}")
|
||||
|
||||
|
||||
async def test_cookbook_list_recipes(nc_client: NextcloudClient):
|
||||
"""Test listing all recipes."""
|
||||
logger.info("Listing all recipes")
|
||||
recipes = await nc_client.cookbook.list_recipes()
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes")
|
||||
|
||||
|
||||
async def test_cookbook_create_and_read_recipe(nc_client: NextcloudClient):
|
||||
"""Test creating a recipe and reading it back."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe for integration testing",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": [
|
||||
"Mix ingredients",
|
||||
"Cook for 20 minutes",
|
||||
"Serve hot",
|
||||
],
|
||||
"recipeCategory": "Test",
|
||||
"keywords": "test,integration",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
logger.info(f"Created recipe with ID: {recipe_id}")
|
||||
|
||||
try:
|
||||
# Read the recipe back
|
||||
logger.info(f"Reading recipe ID: {recipe_id}")
|
||||
retrieved_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
assert retrieved_recipe["name"] == recipe_name
|
||||
assert (
|
||||
retrieved_recipe["description"] == "A test recipe for integration testing"
|
||||
)
|
||||
assert len(retrieved_recipe["recipeIngredient"]) == 3
|
||||
assert len(retrieved_recipe["recipeInstructions"]) == 3
|
||||
assert retrieved_recipe["recipeCategory"] == "Test"
|
||||
assert retrieved_recipe["recipeYield"] == 4
|
||||
logger.info(f"Successfully verified recipe: {recipe_name}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info(f"Successfully deleted recipe ID: {recipe_id}")
|
||||
|
||||
|
||||
async def test_cookbook_update_recipe(nc_client: NextcloudClient):
|
||||
"""Test updating a recipe."""
|
||||
# Create a test recipe
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Get the current recipe first
|
||||
current_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
|
||||
# Update the recipe with all required fields
|
||||
updated_data = current_recipe.copy()
|
||||
updated_data["description"] = "Updated description"
|
||||
updated_data["recipeIngredient"] = ["100g flour", "2 eggs"]
|
||||
updated_data["recipeInstructions"] = ["Mix ingredients", "Cook"]
|
||||
updated_data["recipeCategory"] = "Updated"
|
||||
|
||||
logger.info(f"Updating recipe ID: {recipe_id}")
|
||||
updated_id = await nc_client.cookbook.update_recipe(recipe_id, updated_data)
|
||||
assert updated_id == recipe_id
|
||||
|
||||
# Verify the update
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert updated_recipe["description"] == "Updated description"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
logger.info(f"Successfully updated recipe ID: {recipe_id}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
"""Test deleting a non-existent recipe.
|
||||
|
||||
Note: The Cookbook API may return 502 or succeed silently for non-existent IDs
|
||||
rather than 404. This test verifies the behavior."""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to delete non-existent recipe ID: {non_existent_id}")
|
||||
try:
|
||||
result = await nc_client.cookbook.delete_recipe(non_existent_id)
|
||||
logger.info(f"Delete returned: {result}")
|
||||
# API may succeed silently or return an error message
|
||||
assert isinstance(result, str)
|
||||
except HTTPStatusError as e:
|
||||
# API may return 404 or 502 for non-existent recipes
|
||||
assert e.response.status_code in [404, 502]
|
||||
logger.info(f"Delete correctly failed with {e.response.status_code}")
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
logger.info(f"Importing recipe from nginx container: {test_url}")
|
||||
|
||||
try:
|
||||
imported_recipe = await nc_client.cookbook.import_recipe(test_url)
|
||||
logger.info(f"Successfully imported recipe: {imported_recipe.get('name')}")
|
||||
|
||||
# Verify basic recipe structure
|
||||
assert "name" in imported_recipe
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert "id" in imported_recipe
|
||||
|
||||
# Verify schema.org fields were imported correctly
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
recipe_id = int(imported_recipe["id"])
|
||||
|
||||
# Verify we can read it back
|
||||
retrieved = await nc_client.cookbook.get_recipe(recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {recipe_id}")
|
||||
|
||||
# Clean up
|
||||
logger.info(f"Deleting imported recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
logger.info("Successfully deleted imported recipe")
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Recipe already exists - this is acceptable in tests
|
||||
logger.warning("Recipe already exists (409 conflict)")
|
||||
pytest.skip("Recipe already exists in test environment")
|
||||
elif e.response.status_code == 400:
|
||||
# URL couldn't be imported
|
||||
logger.error(
|
||||
f"Failed to import recipe from nginx container: {test_url}. "
|
||||
f"Status: {e.response.status_code}, Response: {e.response.text}"
|
||||
)
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(nc_client: NextcloudClient):
|
||||
"""Test searching for recipes."""
|
||||
# Create a test recipe with unique keywords
|
||||
unique_keyword = f"testkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Search for the recipe
|
||||
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
||||
search_results = await nc_client.cookbook.search_recipes(unique_keyword)
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
# Should find at least our recipe
|
||||
assert len(search_results) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {recipe_id} not found in search results"
|
||||
logger.info(f"Successfully found recipe {recipe_id} in search results")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(nc_client: NextcloudClient):
|
||||
"""Test listing recipe categories."""
|
||||
logger.info("Listing recipe categories")
|
||||
categories = await nc_client.cookbook.list_categories()
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories")
|
||||
|
||||
# Each category should have name and recipe_count
|
||||
if categories:
|
||||
assert "name" in categories[0]
|
||||
assert "recipe_count" in categories[0]
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
|
||||
"""Test getting recipes in a specific category."""
|
||||
# Create a recipe in a test category
|
||||
unique_category = f"TestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get recipes in this category
|
||||
logger.info(f"Getting recipes in category: {unique_category}")
|
||||
recipes_in_category = await nc_client.cookbook.get_recipes_in_category(
|
||||
unique_category
|
||||
)
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(recipe_id) for r in recipes_in_category)
|
||||
assert found, f"Recipe {recipe_id} not found in category {unique_category}"
|
||||
logger.info(f"Successfully found recipe in category {unique_category}")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
|
||||
"""Test listing recipe keywords."""
|
||||
logger.info("Listing recipe keywords")
|
||||
keywords = await nc_client.cookbook.list_keywords()
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords")
|
||||
|
||||
# Each keyword should have name and recipe_count
|
||||
if keywords:
|
||||
assert "name" in keywords[0]
|
||||
assert "recipe_count" in keywords[0]
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
||||
"""Test getting recipes with specific keywords.
|
||||
|
||||
Note: The keywords filtering may require exact keyword matches and sufficient
|
||||
indexing time. This test uses a longer wait time."""
|
||||
# Create a recipe with unique keywords
|
||||
unique_keyword = f"testtag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"Test Recipe {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},integration",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
try:
|
||||
# Allow extra time for indexing
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Trigger a reindex to ensure the recipe is indexed
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get recipes with this keyword
|
||||
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
||||
recipes_with_keywords = await nc_client.cookbook.get_recipes_with_keywords(
|
||||
[unique_keyword]
|
||||
)
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
# Log the results for debugging
|
||||
logger.info(
|
||||
f"Found {len(recipes_with_keywords)} recipes with keyword {unique_keyword}"
|
||||
)
|
||||
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(recipe_id) for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(f"Successfully found recipe with keyword {unique_keyword}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {recipe_id} not in keyword results, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
logger.info(f"Deleting recipe ID: {recipe_id}")
|
||||
await nc_client.cookbook.delete_recipe(recipe_id)
|
||||
|
||||
|
||||
async def test_cookbook_reindex(nc_client: NextcloudClient):
|
||||
"""Test triggering a reindex of recipes."""
|
||||
logger.info("Triggering recipe reindex")
|
||||
result = await nc_client.cookbook.reindex()
|
||||
|
||||
# Should return a success message
|
||||
assert isinstance(result, str)
|
||||
logger.info(f"Reindex result: {result}")
|
||||
@@ -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,41 +0,0 @@
|
||||
"""Interactive integration tests for OAuth authentication."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"GITHUB_ACTIONS" in os.environ,
|
||||
reason="Unable to access interactive browser in GitHub Actions",
|
||||
)
|
||||
async def test_oauth_client_with_interactive_flow(nc_oauth_client_interactive):
|
||||
"""Test that OAuth client created via interactive flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_interactive.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (interactive) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_interactive.notes.get_all_notes()
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (interactive) successfully listed {len(notes)} notes")
|
||||
|
||||
# Test 3: Create and delete a note
|
||||
test_note = await nc_oauth_client_interactive.notes.create_note(
|
||||
title="OAuth Interactive Test Note",
|
||||
content="This note was created during OAuth interactive testing",
|
||||
)
|
||||
assert test_note is not None
|
||||
assert test_note.get("id") is not None
|
||||
note_id = test_note["id"]
|
||||
logger.info(f"OAuth client (interactive) successfully created note {note_id}")
|
||||
|
||||
# Clean up
|
||||
await nc_oauth_client_interactive.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"OAuth client (interactive) successfully deleted note {note_id}")
|
||||
@@ -19,14 +19,14 @@ async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str):
|
||||
)
|
||||
|
||||
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client_playwright):
|
||||
async def test_oauth_client_with_playwright_flow(nc_oauth_client):
|
||||
"""Test that OAuth client created via Playwright flow can access Nextcloud APIs."""
|
||||
# Test 1: Check capabilities
|
||||
capabilities = await nc_oauth_client_playwright.capabilities()
|
||||
capabilities = await nc_oauth_client.capabilities()
|
||||
assert capabilities is not None
|
||||
logger.info("OAuth client (Playwright) successfully fetched capabilities")
|
||||
|
||||
# Test 2: List notes
|
||||
notes = await nc_oauth_client_playwright.notes.get_all_notes()
|
||||
notes = [note async for note in nc_oauth_client.notes.get_all_notes()]
|
||||
assert isinstance(notes, list)
|
||||
logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes")
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Integration tests for Nextcloud Sharing API client."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@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
|
||||
test_user = "testuser3"
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
|
||||
)
|
||||
except Exception:
|
||||
pass # User might already exist
|
||||
|
||||
# Create a test file
|
||||
file_path = "/test_share_file.txt"
|
||||
file_content = b"Test file for sharing"
|
||||
|
||||
await nc_client.webdav.write_file(file_path, file_content)
|
||||
|
||||
share_id = None
|
||||
try:
|
||||
# Create a share
|
||||
share_data = await nc_client.sharing.create_share(
|
||||
path=file_path,
|
||||
share_with=test_user, # Share with test user
|
||||
share_type=0, # User share
|
||||
permissions=1, # Read-only
|
||||
)
|
||||
|
||||
assert share_data is not None
|
||||
assert "id" in share_data
|
||||
share_id = share_data["id"]
|
||||
logger.info(f"Created share: {share_id}")
|
||||
|
||||
# Get share info
|
||||
share_info = await nc_client.sharing.get_share(share_id)
|
||||
assert share_info["id"] == share_id
|
||||
assert share_info["path"] == file_path
|
||||
assert share_info["permissions"] == 1
|
||||
|
||||
# List shares
|
||||
shares = await nc_client.sharing.list_shares(path=file_path)
|
||||
assert len(shares) > 0
|
||||
assert any(s["id"] == share_id for s in shares)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if share_id:
|
||||
await nc_client.sharing.delete_share(share_id)
|
||||
logger.info(f"Deleted share: {share_id}")
|
||||
|
||||
await nc_client.webdav.delete_resource(file_path)
|
||||
|
||||
# Cleanup test user
|
||||
try:
|
||||
await nc_client.users.delete_user(test_user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_share_permissions(nc_client):
|
||||
"""Test updating share permissions."""
|
||||
# Create a test user to share with
|
||||
test_user = "testuser3"
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
|
||||
)
|
||||
except Exception:
|
||||
pass # User might already exist
|
||||
|
||||
# Create a test file
|
||||
file_path = "/test_share_update.txt"
|
||||
file_content = b"Test file for permission updates"
|
||||
|
||||
await nc_client.webdav.write_file(file_path, file_content)
|
||||
|
||||
share_id = None
|
||||
try:
|
||||
# Create a share with read-only permissions
|
||||
share_data = await nc_client.sharing.create_share(
|
||||
path=file_path,
|
||||
share_with=test_user,
|
||||
share_type=0,
|
||||
permissions=1, # Read-only
|
||||
)
|
||||
share_id = share_data["id"]
|
||||
|
||||
# Update to read+write permissions
|
||||
updated_share = await nc_client.sharing.update_share(
|
||||
share_id=share_id,
|
||||
permissions=3, # Read + Write
|
||||
)
|
||||
|
||||
assert updated_share["id"] == share_id
|
||||
assert updated_share["permissions"] == 3
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if share_id:
|
||||
await nc_client.sharing.delete_share(share_id)
|
||||
|
||||
await nc_client.webdav.delete_resource(file_path)
|
||||
|
||||
# Cleanup test user
|
||||
try:
|
||||
await nc_client.users.delete_user(test_user)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_shares(nc_client):
|
||||
"""Test listing all shares."""
|
||||
# Create a test user to share with
|
||||
test_user = "testuser3"
|
||||
try:
|
||||
await nc_client.users.create_user(
|
||||
userid=test_user, password="SecureP@ssw0rd!2024TestUser"
|
||||
)
|
||||
except Exception:
|
||||
pass # User might already exist
|
||||
|
||||
# Create a test file
|
||||
file_path = "/test_list_shares.txt"
|
||||
file_content = b"Test file for listing shares"
|
||||
|
||||
await nc_client.webdav.write_file(file_path, file_content)
|
||||
|
||||
share_id = None
|
||||
try:
|
||||
# Create a share
|
||||
share_data = await nc_client.sharing.create_share(
|
||||
path=file_path,
|
||||
share_with=test_user,
|
||||
share_type=0,
|
||||
permissions=1,
|
||||
)
|
||||
share_id = share_data["id"]
|
||||
|
||||
# List all shares
|
||||
all_shares = await nc_client.sharing.list_shares()
|
||||
assert len(all_shares) > 0
|
||||
|
||||
# List shares for specific file
|
||||
file_shares = await nc_client.sharing.list_shares(path=file_path)
|
||||
assert len(file_shares) > 0
|
||||
assert any(s["id"] == share_id for s in file_shares)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if share_id:
|
||||
await nc_client.sharing.delete_share(share_id)
|
||||
|
||||
await nc_client.webdav.delete_resource(file_path)
|
||||
|
||||
# Cleanup test user
|
||||
try:
|
||||
await nc_client.users.delete_user(test_user)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Integration tests for WebDAV search operations."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_search_setup(nc_client: NextcloudClient):
|
||||
"""Create test files and directories for search testing."""
|
||||
test_dir = f"mcp_search_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create base directory
|
||||
await nc_client.webdav.create_directory(test_dir)
|
||||
|
||||
# Create various test files
|
||||
test_files = [
|
||||
# Text files
|
||||
(f"{test_dir}/document1.txt", b"Sample document content", "text/plain"),
|
||||
(f"{test_dir}/document2.txt", b"Another document", "text/plain"),
|
||||
(f"{test_dir}/report.txt", b"Report content", "text/plain"),
|
||||
# Markdown files
|
||||
(f"{test_dir}/readme.md", b"# README\nMarkdown content", "text/markdown"),
|
||||
(f"{test_dir}/notes.md", b"# Notes\nSome notes here", "text/markdown"),
|
||||
# PDF (simulated as binary)
|
||||
(
|
||||
f"{test_dir}/presentation.pdf",
|
||||
b"%PDF-1.4 fake pdf content",
|
||||
"application/pdf",
|
||||
),
|
||||
# Subdirectory with files
|
||||
(f"{test_dir}/subdir/nested.txt", b"Nested file content", "text/plain"),
|
||||
]
|
||||
|
||||
# Create subdirectory
|
||||
await nc_client.webdav.create_directory(f"{test_dir}/subdir")
|
||||
|
||||
# Write all test files
|
||||
for file_path, content, content_type in test_files:
|
||||
await nc_client.webdav.write_file(file_path, content, content_type)
|
||||
|
||||
logger.info(f"Created test directory with {len(test_files)} files: {test_dir}")
|
||||
|
||||
yield test_dir
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await nc_client.webdav.delete_resource(test_dir)
|
||||
logger.info(f"Cleaned up test directory: {test_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup test directory {test_dir}: {e}")
|
||||
|
||||
|
||||
async def test_find_by_name_exact(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by exact name."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one readme.md file"
|
||||
|
||||
# Check that we found the right file
|
||||
readme_files = [r for r in results if r.get("name") == "readme.md"]
|
||||
assert len(readme_files) >= 1, "Should find readme.md"
|
||||
|
||||
logger.info(f"Found {len(results)} files matching 'readme.md'")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_extension(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by extension using wildcard."""
|
||||
# Find all .txt files
|
||||
results = await nc_client.webdav.find_by_name("%.txt", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 3, "Should find at least 3 .txt files"
|
||||
|
||||
# Verify all results are .txt files
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
|
||||
logger.info(f"Found {len(results)} .txt files")
|
||||
|
||||
|
||||
async def test_find_by_name_wildcard_prefix(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by name prefix using wildcard."""
|
||||
# Find all files starting with "document"
|
||||
results = await nc_client.webdav.find_by_name("document%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 2, "Should find at least 2 files starting with 'document'"
|
||||
|
||||
# Verify all results start with "document"
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files starting with 'document'")
|
||||
|
||||
|
||||
async def test_find_by_type_text(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test finding files by MIME type (text files)."""
|
||||
# Find all text files
|
||||
results = await nc_client.webdav.find_by_type("text/%", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 5, "Should find at least 5 text files"
|
||||
|
||||
# Verify all results are text files
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type.startswith("text/"), (
|
||||
f"Expected text/* type, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} text files")
|
||||
|
||||
|
||||
async def test_find_by_type_specific(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test finding files by specific MIME type."""
|
||||
# Find PDF files
|
||||
results = await nc_client.webdav.find_by_type(
|
||||
"application/pdf", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find at least 1 PDF file"
|
||||
|
||||
# Verify result is PDF
|
||||
for result in results:
|
||||
content_type = result.get("content_type", "")
|
||||
assert content_type == "application/pdf", (
|
||||
f"Expected application/pdf, got {content_type}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} PDF files")
|
||||
|
||||
|
||||
async def test_search_with_limit(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search with result limit."""
|
||||
# Search for .txt files with limit of 2
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"%.txt", scope=test_search_setup, limit=2
|
||||
)
|
||||
|
||||
# Should return at most 2 results
|
||||
assert len(results) <= 2, f"Should return at most 2 results, got {len(results)}"
|
||||
assert len(results) > 0, "Should return at least 1 result"
|
||||
|
||||
logger.info(f"Found {len(results)} files with limit=2")
|
||||
|
||||
|
||||
async def test_search_files_combined_filters(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test search with multiple filters combined."""
|
||||
# This test uses the search_files method directly to test combined conditions
|
||||
# Search for .txt files that match a specific pattern
|
||||
where_conditions = """
|
||||
<d:and>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>%.txt</d:literal>
|
||||
</d:like>
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
</d:prop>
|
||||
<d:literal>document%</d:literal>
|
||||
</d:like>
|
||||
</d:and>
|
||||
"""
|
||||
|
||||
results = await nc_client.webdav.search_files(
|
||||
scope=test_search_setup, where_conditions=where_conditions
|
||||
)
|
||||
|
||||
# Should find document1.txt and document2.txt
|
||||
assert len(results) >= 2, "Should find at least 2 files matching both conditions"
|
||||
|
||||
# Verify results match both conditions
|
||||
for result in results:
|
||||
name = result.get("name", "")
|
||||
assert name.endswith(".txt"), f"Expected .txt file, got {name}"
|
||||
assert name.startswith("document"), (
|
||||
f"Expected name to start with 'document', got {name}"
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(results)} files matching combined filters")
|
||||
|
||||
|
||||
async def test_search_empty_scope(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search in empty scope (user root)."""
|
||||
# Search entire user root for a unique filename
|
||||
unique_name = "readme.md"
|
||||
results = await nc_client.webdav.find_by_name(unique_name, scope="")
|
||||
|
||||
# Should find at least the one we created
|
||||
assert len(results) >= 1, f"Should find at least 1 file named {unique_name}"
|
||||
|
||||
logger.info(f"Found {len(results)} files in root scope")
|
||||
|
||||
|
||||
async def test_search_subdirectory(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search within a subdirectory."""
|
||||
# Search in the subdir for the nested file
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nested.txt", scope=f"{test_search_setup}/subdir"
|
||||
)
|
||||
|
||||
assert len(results) >= 1, "Should find nested.txt in subdirectory"
|
||||
|
||||
# Verify the file path
|
||||
nested_file = results[0]
|
||||
assert "nested.txt" in nested_file.get("name", ""), "Should find nested.txt"
|
||||
|
||||
logger.info(f"Found file in subdirectory: {nested_file.get('name')}")
|
||||
|
||||
|
||||
async def test_search_no_results(nc_client: NextcloudClient, test_search_setup: str):
|
||||
"""Test search that returns no results."""
|
||||
# Search for a non-existent pattern
|
||||
results = await nc_client.webdav.find_by_name(
|
||||
"nonexistent_file_xyz123.txt", scope=test_search_setup
|
||||
)
|
||||
|
||||
assert len(results) == 0, "Should return empty results for non-existent file"
|
||||
|
||||
logger.info("Search correctly returned no results for non-existent file")
|
||||
|
||||
|
||||
async def test_search_properties_returned(
|
||||
nc_client: NextcloudClient, test_search_setup: str
|
||||
):
|
||||
"""Test that search returns expected properties."""
|
||||
results = await nc_client.webdav.find_by_name("readme.md", scope=test_search_setup)
|
||||
|
||||
assert len(results) >= 1, "Should find at least one file"
|
||||
|
||||
result = results[0]
|
||||
|
||||
# Check for expected properties
|
||||
assert "name" in result, "Should include name property"
|
||||
assert "path" in result, "Should include path property"
|
||||
assert "is_directory" in result, "Should include is_directory property"
|
||||
assert result["is_directory"] is False, "readme.md should not be a directory"
|
||||
|
||||
# Optional properties that may be present
|
||||
optional_props = ["size", "content_type", "last_modified", "etag"]
|
||||
logger.info(f"Result properties: {list(result.keys())}")
|
||||
|
||||
# At least some optional properties should be present
|
||||
present_optional = [prop for prop in optional_props if prop in result]
|
||||
assert len(present_optional) > 0, f"Should have at least one of {optional_props}"
|
||||
|
||||
logger.info(f"Search returned properties: {list(result.keys())}")
|
||||
+1620
-449
File diff suppressed because it is too large
Load Diff
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type text/html;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri.html =404;
|
||||
}
|
||||
|
||||
# Serve test_recipe.html at /black-pepper-tofu
|
||||
location = /black-pepper-tofu {
|
||||
root /usr/share/nginx/html;
|
||||
try_files /test_recipe.html =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+133
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Black Pepper Tofu Recipe - Test Recipe</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Recipe",
|
||||
"name": "Black Pepper Tofu",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Yotam Ottolenghi"
|
||||
},
|
||||
"datePublished": "2024-01-15",
|
||||
"description": "A flavorful black pepper tofu dish with aromatic spices and crispy texture. Inspired by Yotam Ottolenghi's signature style.",
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
"recipeYield": "4",
|
||||
"recipeCategory": "Main Course",
|
||||
"recipeCuisine": "Asian Fusion",
|
||||
"keywords": "tofu, black pepper, vegetarian, vegan, ottolenghi",
|
||||
"image": "https://example.com/black-pepper-tofu.jpg",
|
||||
"recipeIngredient": [
|
||||
"400g firm tofu, pressed and cubed",
|
||||
"2 tablespoons black peppercorns, coarsely ground",
|
||||
"3 tablespoons soy sauce",
|
||||
"2 tablespoons rice vinegar",
|
||||
"1 tablespoon maple syrup",
|
||||
"2 tablespoons cornstarch",
|
||||
"3 tablespoons vegetable oil",
|
||||
"4 cloves garlic, minced",
|
||||
"1 tablespoon fresh ginger, grated",
|
||||
"2 spring onions, sliced",
|
||||
"1 red bell pepper, sliced",
|
||||
"Sesame seeds for garnish"
|
||||
],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Toss tofu cubes with cornstarch until evenly coated."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Add bell pepper and cook for 2-3 minutes until slightly softened."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes."
|
||||
},
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles."
|
||||
}
|
||||
],
|
||||
"nutrition": {
|
||||
"@type": "NutritionInformation",
|
||||
"calories": "280 kcal",
|
||||
"proteinContent": "18 g",
|
||||
"fatContent": "16 g",
|
||||
"carbohydrateContent": "18 g",
|
||||
"fiberContent": "3 g",
|
||||
"servingSize": "1 serving"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>Black Pepper Tofu</h1>
|
||||
<p class="author">By Yotam Ottolenghi</p>
|
||||
<p class="description">
|
||||
A flavorful black pepper tofu dish with aromatic spices and crispy texture.
|
||||
Inspired by Yotam Ottolenghi's signature style.
|
||||
</p>
|
||||
|
||||
<div class="recipe-meta">
|
||||
<p><strong>Prep Time:</strong> 15 minutes</p>
|
||||
<p><strong>Cook Time:</strong> 20 minutes</p>
|
||||
<p><strong>Total Time:</strong> 35 minutes</p>
|
||||
<p><strong>Servings:</strong> 4</p>
|
||||
</div>
|
||||
|
||||
<h2>Ingredients</h2>
|
||||
<ul>
|
||||
<li>400g firm tofu, pressed and cubed</li>
|
||||
<li>2 tablespoons black peppercorns, coarsely ground</li>
|
||||
<li>3 tablespoons soy sauce</li>
|
||||
<li>2 tablespoons rice vinegar</li>
|
||||
<li>1 tablespoon maple syrup</li>
|
||||
<li>2 tablespoons cornstarch</li>
|
||||
<li>3 tablespoons vegetable oil</li>
|
||||
<li>4 cloves garlic, minced</li>
|
||||
<li>1 tablespoon fresh ginger, grated</li>
|
||||
<li>2 spring onions, sliced</li>
|
||||
<li>1 red bell pepper, sliced</li>
|
||||
<li>Sesame seeds for garnish</li>
|
||||
</ul>
|
||||
|
||||
<h2>Instructions</h2>
|
||||
<ol>
|
||||
<li>Press the tofu for at least 15 minutes to remove excess moisture. Cut into 2cm cubes.</li>
|
||||
<li>Toss tofu cubes with cornstarch until evenly coated.</li>
|
||||
<li>Heat 2 tablespoons of oil in a large pan over medium-high heat. Add tofu and cook until golden and crispy on all sides, about 8-10 minutes. Remove and set aside.</li>
|
||||
<li>In the same pan, add remaining oil. Add garlic, ginger, and ground black pepper. Cook for 1 minute until fragrant.</li>
|
||||
<li>Add bell pepper and cook for 2-3 minutes until slightly softened.</li>
|
||||
<li>Mix soy sauce, rice vinegar, and maple syrup. Pour into the pan and bring to a simmer.</li>
|
||||
<li>Return the crispy tofu to the pan and toss to coat in the sauce. Cook for 2 minutes.</li>
|
||||
<li>Garnish with spring onions and sesame seeds. Serve immediately with rice or noodles.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Nutrition Information</h2>
|
||||
<p>Per serving: 280 calories, 18g protein, 16g fat, 18g carbohydrates, 3g fiber</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1 @@
|
||||
"""Load testing utilities for Nextcloud MCP Server."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,554 @@
|
||||
import asyncio
|
||||
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
|
||||
|
||||
|
||||
async def test_mcp_cookbook_create_and_read_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test creating and reading a recipe via MCP tools with verification via NextcloudClient."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Test Recipe {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "A test recipe created via MCP tools",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": ["Mix ingredients", "Cook for 20 minutes", "Serve hot"],
|
||||
"recipeCategory": "MCPTesting",
|
||||
"keywords": f"mcp,testing,{unique_suffix}",
|
||||
"recipeYield": 4,
|
||||
"prepTime": "PT15M",
|
||||
"cookTime": "PT20M",
|
||||
"totalTime": "PT35M",
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via MCP
|
||||
logger.info(f"Creating recipe via MCP: {recipe_name}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_create_recipe",
|
||||
{
|
||||
"name": recipe_name,
|
||||
"description": recipe_data["description"],
|
||||
"ingredients": recipe_data["recipeIngredient"],
|
||||
"instructions": recipe_data["recipeInstructions"],
|
||||
"category": recipe_data["recipeCategory"],
|
||||
"keywords": recipe_data["keywords"],
|
||||
"recipe_yield": recipe_data["recipeYield"],
|
||||
"prep_time": recipe_data["prepTime"],
|
||||
"cook_time": recipe_data["cookTime"],
|
||||
"total_time": recipe_data["totalTime"],
|
||||
},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP recipe creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
create_response = json.loads(create_result.content[0].text)
|
||||
created_recipe_id = create_response["id"]
|
||||
logger.info(f"Recipe created via MCP with ID: {created_recipe_id}")
|
||||
|
||||
# 2. Verify creation via direct NextcloudClient
|
||||
direct_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert direct_recipe["name"] == recipe_name
|
||||
assert direct_recipe["description"] == "A test recipe created via MCP tools"
|
||||
assert len(direct_recipe["recipeIngredient"]) == 3
|
||||
assert len(direct_recipe["recipeInstructions"]) == 3
|
||||
assert direct_recipe["recipeCategory"] == "MCPTesting"
|
||||
|
||||
# 3. Read recipe via MCP
|
||||
logger.info(f"Reading recipe via MCP: {created_recipe_id}")
|
||||
read_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
|
||||
assert read_result.isError is False, (
|
||||
f"MCP recipe read failed: {read_result.content}"
|
||||
)
|
||||
|
||||
read_recipe = json.loads(read_result.content[0].text)
|
||||
assert read_recipe["name"] == recipe_name
|
||||
assert read_recipe["description"] == "A test recipe created via MCP tools"
|
||||
assert len(read_recipe["recipeIngredient"]) == 3
|
||||
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_update_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test updating a recipe via MCP tools."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Update Test {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Original description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Update recipe via MCP (tool handles fetching current recipe internally)
|
||||
logger.info(f"Updating recipe via MCP: {created_recipe_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_update_recipe",
|
||||
{
|
||||
"recipe_id": created_recipe_id,
|
||||
"description": "Updated via MCP",
|
||||
"ingredients": ["100g flour", "2 eggs"],
|
||||
"instructions": ["Mix ingredients", "Cook"],
|
||||
"category": "Updated",
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP recipe update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 4. Verify update via direct NextcloudClient
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert updated_recipe["description"] == "Updated via MCP"
|
||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||
assert len(updated_recipe["recipeInstructions"]) == 2
|
||||
assert updated_recipe["recipeCategory"] == "Updated"
|
||||
|
||||
logger.info(f"Successfully updated recipe {created_recipe_id} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_delete_recipe(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test deleting a recipe via MCP tools."""
|
||||
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
recipe_name = f"MCP Delete Test {unique_suffix}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": "Recipe to be deleted",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for delete test: {recipe_name}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Delete recipe via MCP
|
||||
logger.info(f"Deleting recipe via MCP: {created_recipe_id}")
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_delete_recipe", {"recipe_id": created_recipe_id}
|
||||
)
|
||||
|
||||
assert delete_result.isError is False, (
|
||||
f"MCP recipe deletion failed: {delete_result.content}"
|
||||
)
|
||||
|
||||
# 3. Verify deletion via direct NextcloudClient
|
||||
try:
|
||||
await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
pytest.fail("Recipe should have been deleted but was still found")
|
||||
except Exception:
|
||||
# Expected - recipe should be deleted
|
||||
logger.info(f"Successfully verified recipe {created_recipe_id} was deleted")
|
||||
created_recipe_id = None # Mark as cleaned up
|
||||
|
||||
finally:
|
||||
# Cleanup in case of test failure
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_import_recipe_from_url(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
"""Test importing a recipe from a URL via MCP tools.
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
# Use the nginx container hostname within the Docker network
|
||||
test_url = "http://recipes/black-pepper-tofu"
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Import recipe via MCP
|
||||
logger.info(f"Importing recipe from nginx container via MCP: {test_url}")
|
||||
import_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_import_recipe", {"url": test_url}
|
||||
)
|
||||
|
||||
assert import_result.isError is False, (
|
||||
f"MCP recipe import failed: {import_result.content}"
|
||||
)
|
||||
|
||||
import_response = json.loads(import_result.content[0].text)
|
||||
created_recipe_id = int(import_response["recipe_id"])
|
||||
imported_recipe = import_response["recipe"]
|
||||
|
||||
logger.info(f"Successfully imported recipe via MCP: {imported_recipe['name']}")
|
||||
|
||||
# 2. Verify basic recipe structure
|
||||
assert imported_recipe["name"] == "Black Pepper Tofu"
|
||||
assert imported_recipe.get("description")
|
||||
assert len(imported_recipe.get("recipeIngredient", [])) > 0
|
||||
assert len(imported_recipe.get("recipeInstructions", [])) > 0
|
||||
assert imported_recipe.get("recipeCategory") == "Main Course"
|
||||
assert "tofu" in imported_recipe.get("keywords", "").lower()
|
||||
|
||||
# 3. Verify we can read it back via direct NextcloudClient
|
||||
retrieved = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||
assert retrieved["name"] == imported_recipe["name"]
|
||||
logger.info(f"Verified imported recipe ID: {created_recipe_id}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up imported recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup imported recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_search_recipes(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test searching recipes via MCP tools."""
|
||||
|
||||
unique_keyword = f"mcptestkeyword{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Search Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"description": f"Recipe for testing MCP search with {unique_keyword}",
|
||||
"keywords": unique_keyword,
|
||||
"recipeIngredient": ["test ingredient"],
|
||||
"recipeInstructions": ["test instruction"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe via direct client
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. Search for the recipe via MCP
|
||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||
search_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_search_recipes", {"query": unique_keyword}
|
||||
)
|
||||
|
||||
assert search_result.isError is False, (
|
||||
f"MCP recipe search failed: {search_result.content}"
|
||||
)
|
||||
|
||||
search_response = json.loads(search_result.content[0].text)
|
||||
search_results = search_response["recipes"]
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
assert len(search_results) > 0
|
||||
|
||||
# 4. Verify our recipe is in the results
|
||||
found = any(str(r.get("id")) == str(created_recipe_id) for r in search_results)
|
||||
assert found, f"Recipe {created_recipe_id} not found in search results"
|
||||
logger.info(
|
||||
f"Successfully found recipe {created_recipe_id} in MCP search results"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_list_recipes(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test listing all recipes via MCP tools."""
|
||||
|
||||
logger.info("Listing all recipes via MCP")
|
||||
list_result = await nc_mcp_client.call_tool("nc_cookbook_list_recipes", {})
|
||||
|
||||
assert list_result.isError is False, (
|
||||
f"MCP list recipes failed: {list_result.content}"
|
||||
)
|
||||
|
||||
list_response = json.loads(list_result.content[0].text)
|
||||
recipes = list_response["recipes"]
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes via MCP")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_categories_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test category listing and filtering via MCP tools."""
|
||||
|
||||
unique_category = f"MCPTestCategory{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Category Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"recipeCategory": unique_category,
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe in test category
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. List categories via MCP
|
||||
logger.info("Listing categories via MCP")
|
||||
categories_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_list_categories", {}
|
||||
)
|
||||
|
||||
assert categories_result.isError is False, (
|
||||
f"MCP list categories failed: {categories_result.content}"
|
||||
)
|
||||
|
||||
categories_response = json.loads(categories_result.content[0].text)
|
||||
categories = categories_response["categories"]
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories via MCP")
|
||||
|
||||
# 4. Get recipes in this category via MCP
|
||||
logger.info(f"Getting recipes in category via MCP: {unique_category}")
|
||||
category_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_in_category", {"category": unique_category}
|
||||
)
|
||||
|
||||
assert category_recipes_result.isError is False, (
|
||||
f"MCP get recipes in category failed: {category_recipes_result.content}"
|
||||
)
|
||||
|
||||
category_recipes_response = json.loads(category_recipes_result.content[0].text)
|
||||
recipes_in_category = category_recipes_response["recipes"]
|
||||
|
||||
assert isinstance(recipes_in_category, list)
|
||||
assert len(recipes_in_category) > 0
|
||||
|
||||
# 5. Verify our recipe is in the results
|
||||
found = any(
|
||||
str(r.get("id")) == str(created_recipe_id) for r in recipes_in_category
|
||||
)
|
||||
assert found, (
|
||||
f"Recipe {created_recipe_id} not found in category {unique_category}"
|
||||
)
|
||||
logger.info(f"Successfully found recipe in category {unique_category} via MCP")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_keywords_workflow(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test keyword listing and filtering via MCP tools."""
|
||||
|
||||
unique_keyword = f"mcptesttag{uuid.uuid4().hex[:8]}"
|
||||
recipe_name = f"MCP Keyword Test {uuid.uuid4().hex[:8]}"
|
||||
recipe_data = {
|
||||
"name": recipe_name,
|
||||
"keywords": f"{unique_keyword},mcptesting",
|
||||
"recipeIngredient": ["test"],
|
||||
"recipeInstructions": ["test"],
|
||||
}
|
||||
|
||||
created_recipe_id = None
|
||||
|
||||
try:
|
||||
# 1. Create recipe with test keywords
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow extra time for indexing and trigger reindex
|
||||
await asyncio.sleep(3)
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# 3. List keywords via MCP
|
||||
logger.info("Listing keywords via MCP")
|
||||
keywords_result = await nc_mcp_client.call_tool("nc_cookbook_list_keywords", {})
|
||||
|
||||
assert keywords_result.isError is False, (
|
||||
f"MCP list keywords failed: {keywords_result.content}"
|
||||
)
|
||||
|
||||
keywords_response = json.loads(keywords_result.content[0].text)
|
||||
keywords = keywords_response["keywords"]
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords via MCP")
|
||||
|
||||
# 4. Get recipes with this keyword via MCP
|
||||
logger.info(f"Getting recipes with keyword via MCP: {unique_keyword}")
|
||||
keyword_recipes_result = await nc_mcp_client.call_tool(
|
||||
"nc_cookbook_get_recipes_with_keywords", {"keywords": [unique_keyword]}
|
||||
)
|
||||
|
||||
assert keyword_recipes_result.isError is False, (
|
||||
f"MCP get recipes with keywords failed: {keyword_recipes_result.content}"
|
||||
)
|
||||
|
||||
keyword_recipes_response = json.loads(keyword_recipes_result.content[0].text)
|
||||
recipes_with_keywords = keyword_recipes_response["recipes"]
|
||||
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
|
||||
# Keyword filtering might not find recipes immediately due to indexing
|
||||
if len(recipes_with_keywords) > 0:
|
||||
# Verify our recipe is in the results if any are found
|
||||
found = any(
|
||||
str(r.get("id")) == str(created_recipe_id)
|
||||
for r in recipes_with_keywords
|
||||
)
|
||||
if found:
|
||||
logger.info(
|
||||
f"Successfully found recipe with keyword {unique_keyword} via MCP"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Recipe {created_recipe_id} not in keyword results via MCP, but other recipes found"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"No recipes found with keyword {unique_keyword} via MCP - may be indexing delay"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if created_recipe_id is not None:
|
||||
try:
|
||||
await nc_client.cookbook.delete_recipe(created_recipe_id)
|
||||
logger.info(f"Cleaned up recipe {created_recipe_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup recipe: {e}")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_config_and_version(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test getting Cookbook configuration and version via MCP resources."""
|
||||
|
||||
# 1. Get version via MCP resource
|
||||
logger.info("Getting Cookbook version via MCP resource")
|
||||
version_result = await nc_mcp_client.read_resource("cookbook://version")
|
||||
|
||||
assert len(version_result.contents) > 0
|
||||
version_response = json.loads(version_result.contents[0].text)
|
||||
assert "cookbook_version" in version_response
|
||||
assert "api_version" in version_response
|
||||
logger.info(f"Cookbook version from MCP: {version_response}")
|
||||
|
||||
# 2. Verify version via direct NextcloudClient
|
||||
direct_version = await nc_client.cookbook.get_version()
|
||||
assert direct_version["cookbook_version"] == version_response["cookbook_version"]
|
||||
assert (
|
||||
direct_version["api_version"]["epoch"]
|
||||
== version_response["api_version"]["epoch"]
|
||||
)
|
||||
|
||||
# 3. Get config via MCP resource
|
||||
logger.info("Getting Cookbook config via MCP resource")
|
||||
config_result = await nc_mcp_client.read_resource("cookbook://config")
|
||||
|
||||
assert len(config_result.contents) > 0
|
||||
config_response = json.loads(config_result.contents[0].text)
|
||||
assert isinstance(config_response, dict)
|
||||
logger.info(f"Cookbook config from MCP: {config_response}")
|
||||
|
||||
# 4. Verify config via direct NextcloudClient
|
||||
direct_config = await nc_client.cookbook.get_config()
|
||||
# Both should be dicts - exact match may vary based on config
|
||||
assert isinstance(config_response, dict)
|
||||
assert isinstance(direct_config, dict)
|
||||
|
||||
logger.info("Successfully verified Cookbook version and config via MCP")
|
||||
|
||||
|
||||
async def test_mcp_cookbook_reindex(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient
|
||||
):
|
||||
"""Test triggering a recipe reindex via MCP tools."""
|
||||
|
||||
logger.info("Triggering recipe reindex via MCP")
|
||||
reindex_result = await nc_mcp_client.call_tool("nc_cookbook_reindex", {})
|
||||
|
||||
assert reindex_result.isError is False, (
|
||||
f"MCP reindex failed: {reindex_result.content}"
|
||||
)
|
||||
|
||||
reindex_response = json.loads(reindex_result.content[0].text)
|
||||
assert isinstance(reindex_response["message"], str)
|
||||
logger.info(f"Reindex result from MCP: {reindex_response['message']}")
|
||||
@@ -0,0 +1,569 @@
|
||||
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
|
||||
|
||||
|
||||
# Stack MCP Tools Tests
|
||||
async def test_deck_stack_mcp_tools(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""Test complete deck stack operations via MCP tools."""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"MCP Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
|
||||
# 1. Create stack via MCP tool
|
||||
logger.info(f"Creating stack via MCP: {stack_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_stack",
|
||||
{"board_id": board_id, "title": stack_title, "order": stack_order},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP stack creation failed: {create_result.content}"
|
||||
)
|
||||
created_stack_response = json.loads(create_result.content[0].text)
|
||||
stack_id = created_stack_response["id"]
|
||||
assert created_stack_response["title"] == stack_title
|
||||
assert created_stack_response["order"] == stack_order
|
||||
logger.info(f"Stack created via MCP with ID: {stack_id}")
|
||||
|
||||
try:
|
||||
# 2. Get stack via MCP resource
|
||||
logger.info(f"Getting stack via MCP resource: {stack_id}")
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
|
||||
assert len(get_result.contents) == 1, "Expected exactly one content item"
|
||||
get_stack_response = json.loads(get_result.contents[0].text)
|
||||
assert get_stack_response["title"] == stack_title
|
||||
logger.info("Stack retrieved via MCP resource successfully")
|
||||
|
||||
# 3. Update stack via MCP tool
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
logger.info(f"Updating stack via MCP tool: {stack_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_stack",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"title": updated_title,
|
||||
"order": updated_order,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP stack update failed: {update_result.content}"
|
||||
)
|
||||
logger.info("Stack updated via MCP tool successfully")
|
||||
|
||||
# 4. Verify update via direct client
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info("Stack update verified via direct client")
|
||||
|
||||
# 5. List stacks via MCP resource
|
||||
logger.info("Listing stacks via MCP resource")
|
||||
list_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks"
|
||||
)
|
||||
|
||||
assert len(list_result.contents) == 1, "Expected exactly one content item"
|
||||
stacks_data = json.loads(list_result.contents[0].text)
|
||||
assert isinstance(stacks_data, list)
|
||||
|
||||
# Verify our stack is in the list
|
||||
stack_ids = [stack["id"] for stack in stacks_data]
|
||||
assert stack_id in stack_ids, "Updated stack not found in list"
|
||||
logger.info(f"Stack {stack_id} found in stacks list")
|
||||
|
||||
# 6. Read stack via MCP resource
|
||||
logger.info(f"Reading stack via MCP resource: {stack_id}")
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}"
|
||||
)
|
||||
read_stack_data = json.loads(read_result.contents[0].text)
|
||||
assert read_stack_data["title"] == updated_title
|
||||
logger.info("Stack read via MCP resource successfully")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_stack(board_id, stack_id)
|
||||
logger.info(f"Cleaned up stack ID: {stack_id}")
|
||||
|
||||
|
||||
# Card MCP Tools Tests
|
||||
async def test_deck_card_mcp_tools(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
temporary_board_with_stack: tuple,
|
||||
):
|
||||
"""Test complete deck card operations via MCP tools."""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
card_title = f"MCP Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for {card_title}"
|
||||
|
||||
# 1. Create card via MCP tool
|
||||
logger.info(f"Creating card via MCP: {card_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"title": card_title,
|
||||
"description": card_description,
|
||||
"type": "plain",
|
||||
"order": 1,
|
||||
},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP card creation failed: {create_result.content}"
|
||||
)
|
||||
created_card_response = json.loads(create_result.content[0].text)
|
||||
card_id = created_card_response["id"]
|
||||
assert created_card_response["title"] == card_title
|
||||
assert created_card_response["description"] == card_description
|
||||
logger.info(f"Card created via MCP with ID: {card_id}")
|
||||
|
||||
try:
|
||||
# 2. Get card via MCP resource
|
||||
logger.info(f"Getting card via MCP resource: {card_id}")
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
|
||||
)
|
||||
|
||||
assert len(get_result.contents) == 1, "Expected exactly one content item"
|
||||
get_card_response = json.loads(get_result.contents[0].text)
|
||||
assert get_card_response["title"] == card_title
|
||||
logger.info("Card retrieved via MCP resource successfully")
|
||||
|
||||
# 3. Update card via MCP tool
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
logger.info(f"Updating card via MCP tool: {card_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"title": updated_title,
|
||||
"description": updated_description,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP card update failed: {update_result.content}"
|
||||
)
|
||||
logger.info("Card updated via MCP tool successfully")
|
||||
|
||||
# 4. Verify update via direct client
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info("Card update verified via direct client")
|
||||
|
||||
# 5. Archive/unarchive card via MCP tools
|
||||
logger.info(f"Archiving card via MCP tool: {card_id}")
|
||||
archive_result = await nc_mcp_client.call_tool(
|
||||
"deck_archive_card",
|
||||
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
|
||||
)
|
||||
|
||||
assert archive_result.isError is False, (
|
||||
f"MCP card archive failed: {archive_result.content}"
|
||||
)
|
||||
logger.info("Card archived via MCP tool successfully")
|
||||
|
||||
logger.info(f"Unarchiving card via MCP tool: {card_id}")
|
||||
unarchive_result = await nc_mcp_client.call_tool(
|
||||
"deck_unarchive_card",
|
||||
{"board_id": board_id, "stack_id": stack_id, "card_id": card_id},
|
||||
)
|
||||
|
||||
assert unarchive_result.isError is False, (
|
||||
f"MCP card unarchive failed: {unarchive_result.content}"
|
||||
)
|
||||
logger.info("Card unarchived via MCP tool successfully")
|
||||
|
||||
# 6. Move card to different position via MCP tool
|
||||
logger.info(f"Reordering card via MCP tool: {card_id}")
|
||||
reorder_result = await nc_mcp_client.call_tool(
|
||||
"deck_reorder_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"order": 10,
|
||||
"target_stack_id": stack_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert reorder_result.isError is False, (
|
||||
f"MCP card reorder failed: {reorder_result.content}"
|
||||
)
|
||||
logger.info("Card reordered via MCP tool successfully")
|
||||
|
||||
# 7. Read card via MCP resource
|
||||
logger.info(f"Reading card via MCP resource: {card_id}")
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}"
|
||||
)
|
||||
read_card_data = json.loads(read_result.contents[0].text)
|
||||
assert read_card_data["title"] == updated_title
|
||||
logger.info("Card read via MCP resource successfully")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Cleaned up card ID: {card_id}")
|
||||
|
||||
|
||||
# Label MCP Tools Tests
|
||||
async def test_deck_label_mcp_tools(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""Test complete deck label operations via MCP tools."""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"MCP Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
|
||||
# 1. Create label via MCP tool
|
||||
logger.info(f"Creating label via MCP: {label_title}")
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_label",
|
||||
{"board_id": board_id, "title": label_title, "color": label_color},
|
||||
)
|
||||
|
||||
assert create_result.isError is False, (
|
||||
f"MCP label creation failed: {create_result.content}"
|
||||
)
|
||||
created_label_response = json.loads(create_result.content[0].text)
|
||||
label_id = created_label_response["id"]
|
||||
assert created_label_response["title"] == label_title
|
||||
assert created_label_response["color"] == label_color
|
||||
logger.info(f"Label created via MCP with ID: {label_id}")
|
||||
|
||||
try:
|
||||
# 2. Get label via MCP resource
|
||||
logger.info(f"Getting label via MCP resource: {label_id}")
|
||||
get_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
|
||||
assert len(get_result.contents) == 1, "Expected exactly one content item"
|
||||
get_label_response = json.loads(get_result.contents[0].text)
|
||||
assert get_label_response["title"] == label_title
|
||||
logger.info("Label retrieved via MCP resource successfully")
|
||||
|
||||
# 3. Update label via MCP tool
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
logger.info(f"Updating label via MCP tool: {label_id}")
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"deck_update_label",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"label_id": label_id,
|
||||
"title": updated_title,
|
||||
"color": updated_color,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_result.isError is False, (
|
||||
f"MCP label update failed: {update_result.content}"
|
||||
)
|
||||
logger.info("Label updated via MCP tool successfully")
|
||||
|
||||
# 4. Verify update via direct client
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info("Label update verified via direct client")
|
||||
|
||||
# 5. Read label via MCP resource
|
||||
logger.info(f"Reading label via MCP resource: {label_id}")
|
||||
read_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels/{label_id}"
|
||||
)
|
||||
read_label_data = json.loads(read_result.contents[0].text)
|
||||
assert read_label_data["title"] == updated_title
|
||||
logger.info("Label read via MCP resource successfully")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_label(board_id, label_id)
|
||||
logger.info(f"Cleaned up label ID: {label_id}")
|
||||
|
||||
|
||||
# Label-Card Assignment Tests
|
||||
async def test_deck_card_label_assignment_mcp_tools(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
temporary_board_with_card: tuple,
|
||||
):
|
||||
"""Test card-label assignment operations via MCP tools."""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
card_id = card_data["id"]
|
||||
|
||||
# Create a label for assignment
|
||||
label = await nc_client.deck.create_label(
|
||||
board_id, "Assignment Test Label", "0000FF"
|
||||
)
|
||||
label_id = label.id
|
||||
|
||||
try:
|
||||
# 1. Assign label to card via MCP tool
|
||||
logger.info(f"Assigning label {label_id} to card {card_id} via MCP")
|
||||
assign_result = await nc_mcp_client.call_tool(
|
||||
"deck_assign_label_to_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"label_id": label_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert assign_result.isError is False, (
|
||||
f"MCP label assignment failed: {assign_result.content}"
|
||||
)
|
||||
logger.info("Label assigned to card via MCP tool successfully")
|
||||
|
||||
# 2. Verify assignment via direct client
|
||||
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
if card.labels:
|
||||
label_ids = [label.id for label in card.labels]
|
||||
assert label_id in label_ids, "Label not found in card labels"
|
||||
logger.info("Label assignment verified via direct client")
|
||||
|
||||
# 3. Remove label from card via MCP tool
|
||||
logger.info(f"Removing label {label_id} from card {card_id} via MCP")
|
||||
remove_result = await nc_mcp_client.call_tool(
|
||||
"deck_remove_label_from_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"label_id": label_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert remove_result.isError is False, (
|
||||
f"MCP label removal failed: {remove_result.content}"
|
||||
)
|
||||
logger.info("Label removed from card via MCP tool successfully")
|
||||
|
||||
# 4. Verify removal via direct client
|
||||
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
if card.labels:
|
||||
label_ids = [label.id for label in card.labels]
|
||||
assert label_id not in label_ids, (
|
||||
"Label still found in card labels after removal"
|
||||
)
|
||||
logger.info("Label removal verified via direct client")
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
await nc_client.deck.delete_label(board_id, label_id)
|
||||
logger.info(f"Cleaned up label ID: {label_id}")
|
||||
|
||||
|
||||
# User Assignment Tests
|
||||
async def test_deck_card_user_assignment_mcp_tools(
|
||||
nc_mcp_client: ClientSession,
|
||||
nc_client: NextcloudClient,
|
||||
temporary_board_with_card: tuple,
|
||||
):
|
||||
"""Test card-user assignment operations via MCP tools."""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
card_id = card_data["id"]
|
||||
|
||||
# Use the current user ID (admin in most test environments)
|
||||
user_id = "admin"
|
||||
|
||||
# 1. Assign user to card via MCP tool
|
||||
logger.info(f"Assigning user {user_id} to card {card_id} via MCP")
|
||||
assign_result = await nc_mcp_client.call_tool(
|
||||
"deck_assign_user_to_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert assign_result.isError is False, (
|
||||
f"MCP user assignment failed: {assign_result.content}"
|
||||
)
|
||||
logger.info("User assigned to card via MCP tool successfully")
|
||||
|
||||
# 2. Verify assignment via direct client
|
||||
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
if card.assignedUsers:
|
||||
user_ids = []
|
||||
for user in card.assignedUsers:
|
||||
if hasattr(user, "participant"):
|
||||
# It's a DeckAssignedUser with participant
|
||||
user_ids.append(user.participant.uid)
|
||||
elif hasattr(user, "uid"):
|
||||
# It's a direct DeckUser
|
||||
user_ids.append(user.uid)
|
||||
assert user_id in user_ids, "User not found in card assigned users"
|
||||
logger.info("User assignment verified via direct client")
|
||||
|
||||
# 3. Unassign user from card via MCP tool
|
||||
logger.info(f"Unassigning user {user_id} from card {card_id} via MCP")
|
||||
unassign_result = await nc_mcp_client.call_tool(
|
||||
"deck_unassign_user_from_card",
|
||||
{
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"card_id": card_id,
|
||||
"user_id": user_id,
|
||||
},
|
||||
)
|
||||
|
||||
assert unassign_result.isError is False, (
|
||||
f"MCP user unassignment failed: {unassign_result.content}"
|
||||
)
|
||||
logger.info("User unassigned from card via MCP tool successfully")
|
||||
|
||||
# 4. Verify unassignment via direct client
|
||||
card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
if card.assignedUsers:
|
||||
user_ids = []
|
||||
for user in card.assignedUsers:
|
||||
if hasattr(user, "participant"):
|
||||
# It's a DeckAssignedUser with participant
|
||||
user_ids.append(user.participant.uid)
|
||||
elif hasattr(user, "uid"):
|
||||
# It's a direct DeckUser
|
||||
user_ids.append(user.uid)
|
||||
assert user_id not in user_ids, (
|
||||
"User still found in card assigned users after removal"
|
||||
)
|
||||
logger.info("User unassignment verified via direct client")
|
||||
|
||||
|
||||
# Error handling tests
|
||||
async def test_deck_mcp_tools_error_handling(nc_mcp_client: ClientSession):
|
||||
"""Test error handling for deck MCP tools with invalid parameters."""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test stack operations with non-existent board
|
||||
stack_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_stack",
|
||||
{"board_id": non_existent_id, "title": "Should Fail", "order": 1},
|
||||
)
|
||||
assert stack_result.isError is True, (
|
||||
"Expected error for stack creation on non-existent board"
|
||||
)
|
||||
|
||||
# Test card operations with non-existent IDs
|
||||
card_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_card",
|
||||
{
|
||||
"board_id": non_existent_id,
|
||||
"stack_id": non_existent_id,
|
||||
"title": "Should Fail",
|
||||
"type": "plain",
|
||||
},
|
||||
)
|
||||
assert card_result.isError is True, (
|
||||
"Expected error for card creation with non-existent IDs"
|
||||
)
|
||||
|
||||
# Test label operations with non-existent board
|
||||
label_result = await nc_mcp_client.call_tool(
|
||||
"deck_create_label",
|
||||
{"board_id": non_existent_id, "title": "Should Fail", "color": "FF0000"},
|
||||
)
|
||||
assert label_result.isError is True, (
|
||||
"Expected error for label creation on non-existent board"
|
||||
)
|
||||
|
||||
logger.info("Error handling tests passed for deck MCP tools")
|
||||
|
||||
|
||||
# Resource template tests
|
||||
async def test_deck_mcp_resource_templates(nc_mcp_client: ClientSession):
|
||||
"""Test deck MCP resource templates are properly registered."""
|
||||
templates = await nc_mcp_client.list_resource_templates()
|
||||
template_uris = [template.uriTemplate for template in templates.resourceTemplates]
|
||||
|
||||
expected_templates = [
|
||||
"nc://Deck/boards/{board_id}/stacks/{stack_id}",
|
||||
"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards/{card_id}",
|
||||
"nc://Deck/boards/{board_id}/labels/{label_id}",
|
||||
]
|
||||
|
||||
for expected_template in expected_templates:
|
||||
assert expected_template in template_uris, (
|
||||
f"Expected template '{expected_template}' not found"
|
||||
)
|
||||
logger.info(f"Found expected deck resource template: {expected_template}")
|
||||
|
||||
|
||||
# Listing resource tests
|
||||
async def test_deck_mcp_listing_resources(
|
||||
nc_mcp_client: ClientSession, temporary_board_with_card: tuple
|
||||
):
|
||||
"""Test deck MCP listing resources for stacks and cards."""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
|
||||
# 1. Test listing stacks resource
|
||||
logger.info(f"Reading stacks list via MCP resource for board {board_id}")
|
||||
stacks_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks"
|
||||
)
|
||||
stacks_resource_data = json.loads(stacks_resource_result.contents[0].text)
|
||||
assert isinstance(stacks_resource_data, list)
|
||||
|
||||
# Verify our stack is in the resource list
|
||||
stack_ids = [stack["id"] for stack in stacks_resource_data]
|
||||
assert stack_id in stack_ids, "Stack not found in stacks resource list"
|
||||
logger.info("Stack found in stacks resource list")
|
||||
|
||||
# 2. Test listing cards resource
|
||||
logger.info(f"Reading cards list via MCP resource for stack {stack_id}")
|
||||
cards_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/stacks/{stack_id}/cards"
|
||||
)
|
||||
cards_resource_data = json.loads(cards_resource_result.contents[0].text)
|
||||
assert isinstance(cards_resource_data, list)
|
||||
|
||||
# Verify our card is in the resource list
|
||||
card_ids = [card["id"] for card in cards_resource_data]
|
||||
assert card_data["id"] in card_ids, "Card not found in cards resource list"
|
||||
logger.info("Card found in cards resource list")
|
||||
|
||||
# 3. Test listing labels resource
|
||||
logger.info(f"Reading labels list via MCP resource for board {board_id}")
|
||||
labels_resource_result = await nc_mcp_client.read_resource(
|
||||
f"nc://Deck/boards/{board_id}/labels"
|
||||
)
|
||||
labels_resource_data = json.loads(labels_resource_result.contents[0].text)
|
||||
assert isinstance(labels_resource_data, list)
|
||||
logger.info("Labels resource read successfully")
|
||||
@@ -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"])
|
||||
@@ -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,7 +57,25 @@ 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",
|
||||
"nc_cookbook_get_recipe",
|
||||
"nc_cookbook_create_recipe",
|
||||
"nc_cookbook_update_recipe",
|
||||
"nc_cookbook_delete_recipe",
|
||||
"nc_cookbook_search_recipes",
|
||||
"nc_cookbook_list_categories",
|
||||
"nc_cookbook_get_recipes_in_category",
|
||||
"nc_cookbook_list_keywords",
|
||||
"nc_cookbook_get_recipes_with_keywords",
|
||||
"nc_cookbook_set_config",
|
||||
"nc_cookbook_reindex",
|
||||
]
|
||||
|
||||
for expected_tool in expected_tools:
|
||||
@@ -85,7 +109,13 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
resource_uris.append(str(resource.uri)) # Convert to string for comparison
|
||||
|
||||
# Verify expected resources
|
||||
expected_resources = ["nc://capabilities", "notes://settings", "nc://Deck/boards"]
|
||||
expected_resources = [
|
||||
"nc://capabilities",
|
||||
"notes://settings",
|
||||
"nc://Deck/boards",
|
||||
"cookbook://version",
|
||||
"cookbook://config",
|
||||
]
|
||||
|
||||
for expected_resource in expected_resources:
|
||||
assert expected_resource in resource_uris, (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,11 +39,11 @@ async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client_playwright):
|
||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||
|
||||
# Test: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client_playwright.call_tool(
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Multi-user OAuth tests for Nextcloud Deck board permissions.
|
||||
|
||||
Tests verify that the MCP server respects Nextcloud Deck board ACL permissions
|
||||
when accessed via OAuth authentication with different users.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def add_board_acl(nc_client, board_id: int, user: str, permission_type: int = 0):
|
||||
"""
|
||||
Helper to add ACL entry to a Deck board.
|
||||
|
||||
Args:
|
||||
nc_client: Admin NextcloudClient
|
||||
board_id: Board ID
|
||||
user: Username to grant access
|
||||
permission_type: 0=view, 1=edit, 2=manage
|
||||
|
||||
Returns:
|
||||
ACL entry ID
|
||||
"""
|
||||
acl = await nc_client.deck.add_acl_rule(
|
||||
board_id=board_id,
|
||||
type=0, # 0 = user, 1 = group
|
||||
participant=user,
|
||||
permission_edit=permission_type >= 1,
|
||||
permission_share=permission_type >= 2,
|
||||
permission_manage=permission_type >= 2,
|
||||
)
|
||||
logger.info(f"Added ACL for board {board_id}: {user} (type={permission_type})")
|
||||
return acl.id
|
||||
|
||||
|
||||
async def delete_board_acl(nc_client, board_id: int, acl_id: int):
|
||||
"""Helper to delete a board ACL entry."""
|
||||
await nc_client.deck.delete_acl_rule(board_id, acl_id)
|
||||
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_view_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that Deck boards respect view permissions.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a board as alice
|
||||
2. Admin adds bob to board with view-only permissions
|
||||
3. Bob can view the board via MCP tools
|
||||
4. Diana cannot access the board (no ACL entry)
|
||||
"""
|
||||
# Create a board as alice
|
||||
logger.info("Creating Deck board as alice...")
|
||||
board = await nc_client.deck.create_board(
|
||||
"Alice's Shared Board - View Test", "FF0000"
|
||||
)
|
||||
board_id = board.id
|
||||
|
||||
bob_acl_id = None
|
||||
|
||||
try:
|
||||
# Add bob to board with view-only permission
|
||||
logger.info("Adding bob to board with view permission...")
|
||||
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
|
||||
|
||||
# Test: Bob can view the board via MCP
|
||||
logger.info("Bob attempting to list boards via MCP...")
|
||||
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
||||
|
||||
# Bob should see the shared board
|
||||
if board_id in board_ids:
|
||||
logger.info(f"Bob can see shared board {board_id}")
|
||||
else:
|
||||
logger.warning(f"Bob cannot see shared board {board_id}")
|
||||
else:
|
||||
logger.warning(f"Bob could not list boards: {result.content}")
|
||||
|
||||
# Test: Diana cannot see the board
|
||||
logger.info("Diana attempting to list boards via MCP...")
|
||||
result = await diana_mcp_client.call_tool("deck_get_boards", arguments={})
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Diana can see {len(response_data)} boards")
|
||||
|
||||
# Diana should NOT see the board
|
||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
||||
logger.info("Diana correctly cannot see board without ACL")
|
||||
else:
|
||||
logger.warning(f"Diana could not list boards: {result.content}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if bob_acl_id:
|
||||
await delete_board_acl(nc_client, board_id, bob_acl_id)
|
||||
logger.info(f"Deleting board {board_id}")
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_edit_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that Deck boards respect edit permissions.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a board as alice with a stack
|
||||
2. Admin adds charlie with edit permission
|
||||
3. Admin adds bob with view-only permission
|
||||
4. Charlie can create cards via MCP tools
|
||||
5. Bob cannot create cards
|
||||
"""
|
||||
# Create a board as alice
|
||||
logger.info("Creating Deck board as alice...")
|
||||
board = await nc_client.deck.create_board(
|
||||
"Alice's Shared Board - Edit Test", "00FF00"
|
||||
)
|
||||
board_id = board.id
|
||||
|
||||
# Create a stack in the board
|
||||
logger.info("Creating stack in board...")
|
||||
stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1)
|
||||
stack_id = stack.id
|
||||
|
||||
charlie_acl_id = None
|
||||
bob_acl_id = None
|
||||
|
||||
try:
|
||||
# Add charlie with edit permission
|
||||
logger.info("Adding charlie to board with edit permission...")
|
||||
charlie_acl_id = await add_board_acl(
|
||||
nc_client, board_id, "charlie", permission_type=1
|
||||
)
|
||||
|
||||
# Add bob with view-only permission
|
||||
logger.info("Adding bob to board with view permission...")
|
||||
bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0)
|
||||
|
||||
# Test: Charlie can create a card
|
||||
logger.info("Charlie attempting to create card via MCP...")
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"deck_create_card",
|
||||
arguments={
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"title": "Charlie's Card",
|
||||
"description": "Created by Charlie with edit permission",
|
||||
},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
card_id = response_data.get("id")
|
||||
logger.info(f"Charlie successfully created card {card_id}")
|
||||
|
||||
# Cleanup the card
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card_id)
|
||||
else:
|
||||
logger.warning(f"Charlie could not create card: {result.content}")
|
||||
|
||||
# Test: Bob attempts to create a card (should fail)
|
||||
logger.info("Bob attempting to create card via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"deck_create_card",
|
||||
arguments={
|
||||
"board_id": board_id,
|
||||
"stack_id": stack_id,
|
||||
"title": "Bob's Card",
|
||||
"description": "Bob trying to create a card",
|
||||
},
|
||||
)
|
||||
|
||||
if result.isError:
|
||||
logger.info("Bob correctly denied card creation (view-only)")
|
||||
else:
|
||||
logger.warning("Bob unexpectedly succeeded in creating card")
|
||||
# Cleanup if bob somehow created a card
|
||||
response_data = json.loads(result.content[0].text)
|
||||
if "id" in response_data:
|
||||
await nc_client.deck.delete_card(
|
||||
board_id, stack_id, response_data["id"]
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if charlie_acl_id:
|
||||
await delete_board_acl(nc_client, board_id, charlie_acl_id)
|
||||
if bob_acl_id:
|
||||
await delete_board_acl(nc_client, board_id, bob_acl_id)
|
||||
logger.info(f"Deleting board {board_id}")
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_manage_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that Deck boards respect manage permissions.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a board as alice
|
||||
2. Admin adds charlie with manage permission
|
||||
3. Charlie can create stacks and modify board settings
|
||||
"""
|
||||
# Create a board as alice
|
||||
logger.info("Creating Deck board as alice...")
|
||||
board = await nc_client.deck.create_board(
|
||||
"Alice's Shared Board - Manage Test", "0000FF"
|
||||
)
|
||||
board_id = board.id
|
||||
|
||||
charlie_acl_id = None
|
||||
|
||||
try:
|
||||
# Add charlie with manage permission
|
||||
logger.info("Adding charlie to board with manage permission...")
|
||||
charlie_acl_id = await add_board_acl(
|
||||
nc_client, board_id, "charlie", permission_type=2
|
||||
)
|
||||
|
||||
# Test: Charlie can create a stack
|
||||
logger.info("Charlie attempting to create stack via MCP...")
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"deck_create_stack",
|
||||
arguments={"board_id": board_id, "title": "Charlie's Stack", "order": 1},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
stack_id = response_data.get("id")
|
||||
logger.info(f"Charlie successfully created stack {stack_id}")
|
||||
|
||||
# Cleanup the stack
|
||||
await nc_client.deck.delete_stack(board_id, stack_id)
|
||||
else:
|
||||
logger.warning(f"Charlie could not create stack: {result.content}")
|
||||
|
||||
# Test: Charlie can delete a stack (manage permission)
|
||||
logger.info("Charlie attempting to delete stack via MCP...")
|
||||
# First create a temporary stack to delete
|
||||
temp_stack = await nc_client.deck.create_stack(
|
||||
board_id, "Temp Stack for Deletion", 99
|
||||
)
|
||||
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"deck_delete_stack",
|
||||
arguments={"board_id": board_id, "stack_id": temp_stack.id},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
logger.info("Charlie successfully deleted stack")
|
||||
else:
|
||||
logger.warning(f"Charlie could not delete stack: {result.content}")
|
||||
# Cleanup if deletion via MCP failed
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, temp_stack.id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if charlie_acl_id:
|
||||
await delete_board_acl(nc_client, board_id, charlie_acl_id)
|
||||
logger.info(f"Deleting board {board_id}")
|
||||
await nc_client.deck.delete_board(board_id)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a board as alice (not shared)
|
||||
2. Admin creates a board as bob (not shared)
|
||||
3. Alice can only see her own board
|
||||
4. Bob can only see his own board
|
||||
"""
|
||||
# Create alice's board
|
||||
logger.info("Creating alice's private board...")
|
||||
alice_board = await nc_client.deck.create_board("Alice's Private Board", "FF00FF")
|
||||
alice_board_id = alice_board.id
|
||||
|
||||
# Create bob's board
|
||||
logger.info("Creating bob's private board...")
|
||||
bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF")
|
||||
bob_board_id = bob_board.id
|
||||
|
||||
try:
|
||||
# Test: Alice lists boards
|
||||
logger.info("Alice listing boards via MCP...")
|
||||
result = await alice_mcp_client.call_tool("deck_get_boards", arguments={})
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Alice can see boards: {board_ids}")
|
||||
|
||||
# Alice should NOT see Bob's board
|
||||
assert bob_board_id not in board_ids, (
|
||||
"Alice should not see Bob's private board"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Alice could not list boards: {result.content}")
|
||||
|
||||
# Test: Bob lists boards
|
||||
logger.info("Bob listing boards via MCP...")
|
||||
result = await bob_mcp_client.call_tool("deck_get_boards", arguments={})
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Bob can see boards: {board_ids}")
|
||||
|
||||
# Bob should NOT see Alice's board
|
||||
assert alice_board_id not in board_ids, (
|
||||
"Bob should not see Alice's private board"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Bob could not list boards: {result.content}")
|
||||
|
||||
logger.info("User isolation test passed: users can only see their own boards")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
logger.info("Cleaning up test boards...")
|
||||
await nc_client.deck.delete_board(alice_board_id)
|
||||
await nc_client.deck.delete_board(bob_board_id)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Multi-user OAuth tests for Nextcloud WebDAV file permissions.
|
||||
|
||||
Tests verify that the MCP server respects Nextcloud file sharing permissions
|
||||
when accessed via OAuth authentication with different users.
|
||||
|
||||
All operations (file creation, sharing, access) are performed through MCP tools
|
||||
to ensure the MCP server properly supports multi-user scenarios.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_read_permissions(
|
||||
alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that shared files respect read permissions.
|
||||
|
||||
Scenario:
|
||||
1. Alice creates a file via MCP
|
||||
2. Alice shares the file with Bob (read-only) via MCP
|
||||
3. Bob can read the file via MCP tools
|
||||
4. Diana cannot access the file (no share)
|
||||
"""
|
||||
file_path = "/alice_shared_file_read.txt"
|
||||
file_content = "This file is shared with Bob for reading only."
|
||||
|
||||
# Alice creates a file
|
||||
logger.info(f"Alice creating file: {file_path}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": file_path, "content": file_content},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create file: {result.content}"
|
||||
|
||||
share_id = None
|
||||
|
||||
try:
|
||||
# Alice shares the file with bob (read-only, permissions=1)
|
||||
logger.info("Alice sharing file with bob (read-only)...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_share_create",
|
||||
arguments={
|
||||
"path": file_path,
|
||||
"share_with": "bob",
|
||||
"share_type": 0,
|
||||
"permissions": 1,
|
||||
},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create share: {result.content}"
|
||||
share_data = json.loads(result.content[0].text)
|
||||
share_id = share_data["id"]
|
||||
logger.info(f"Created share {share_id}")
|
||||
|
||||
# Test: Bob reads the file via MCP
|
||||
logger.info("Bob attempting to read file via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": file_path}
|
||||
)
|
||||
|
||||
# Bob should be able to read the shared file
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
logger.info(
|
||||
f"Bob successfully read file: {response_data.get('content', '')[:50]}..."
|
||||
)
|
||||
assert "content" in response_data
|
||||
assert file_content in response_data["content"]
|
||||
else:
|
||||
logger.warning(f"Bob could not read file: {result.content}")
|
||||
# This might fail if the share path is different for bob
|
||||
|
||||
# Test: Diana attempts to read the file
|
||||
logger.info("Diana attempting to read file via MCP...")
|
||||
result = await diana_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": file_path}
|
||||
)
|
||||
|
||||
# Diana should NOT be able to read (no share)
|
||||
if result.isError:
|
||||
logger.info("Diana correctly denied access to unshared file")
|
||||
else:
|
||||
logger.warning("Diana unexpectedly could read unshared file")
|
||||
|
||||
finally:
|
||||
# Cleanup - Alice deletes the share and file
|
||||
if share_id:
|
||||
logger.info(f"Alice deleting share {share_id}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_share_delete", arguments={"share_id": share_id}
|
||||
)
|
||||
logger.info(f"Alice deleting file {file_path}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": file_path}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_write_permissions(
|
||||
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that shared files respect write permissions.
|
||||
|
||||
Scenario:
|
||||
1. Alice creates a file via MCP
|
||||
2. Alice shares the file with Charlie (edit permission) via MCP
|
||||
3. Alice shares the file with Bob (read-only) via MCP
|
||||
4. Charlie can edit the file via MCP tools
|
||||
5. Bob cannot edit the file
|
||||
"""
|
||||
file_path = "/alice_shared_file_write.txt"
|
||||
file_content = "This file is shared with Charlie for editing."
|
||||
|
||||
logger.info(f"Alice creating file: {file_path}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": file_path, "content": file_content},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create file: {result.content}"
|
||||
|
||||
charlie_share_id = None
|
||||
bob_share_id = None
|
||||
|
||||
try:
|
||||
# Alice shares with Charlie (read+write, permissions=3)
|
||||
logger.info("Alice sharing file with Charlie (edit permission)...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_share_create",
|
||||
arguments={
|
||||
"path": file_path,
|
||||
"share_with": "charlie",
|
||||
"share_type": 0,
|
||||
"permissions": 3,
|
||||
},
|
||||
)
|
||||
assert not result.isError, (
|
||||
f"Alice failed to share with Charlie: {result.content}"
|
||||
)
|
||||
charlie_share_data = json.loads(result.content[0].text)
|
||||
charlie_share_id = charlie_share_data["id"]
|
||||
logger.info(f"Created share {charlie_share_id} for Charlie")
|
||||
|
||||
# Alice shares with Bob (read-only, permissions=1)
|
||||
logger.info("Alice sharing file with Bob (read-only)...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_share_create",
|
||||
arguments={
|
||||
"path": file_path,
|
||||
"share_with": "bob",
|
||||
"share_type": 0,
|
||||
"permissions": 1,
|
||||
},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to share with Bob: {result.content}"
|
||||
bob_share_data = json.loads(result.content[0].text)
|
||||
bob_share_id = bob_share_data["id"]
|
||||
logger.info(f"Created share {bob_share_id} for Bob")
|
||||
|
||||
# Test: Charlie can write to the file
|
||||
logger.info("Charlie attempting to write to file via MCP...")
|
||||
updated_content = f"{file_content}\nCharlie added this line."
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": file_path, "content": updated_content},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
logger.info("Charlie successfully wrote to file")
|
||||
else:
|
||||
logger.warning(f"Charlie could not write to file: {result.content}")
|
||||
|
||||
# Test: Bob attempts to write (should fail)
|
||||
logger.info("Bob attempting to write to file via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": file_path, "content": "Bob tries to overwrite this."},
|
||||
)
|
||||
|
||||
# Bob should be denied
|
||||
if result.isError:
|
||||
logger.info("Bob correctly denied write access")
|
||||
else:
|
||||
logger.warning("Bob unexpectedly succeeded in writing (permissions issue?)")
|
||||
|
||||
finally:
|
||||
# Cleanup - Alice deletes shares and file
|
||||
if charlie_share_id:
|
||||
logger.info(f"Alice deleting Charlie's share {charlie_share_id}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_share_delete", arguments={"share_id": charlie_share_id}
|
||||
)
|
||||
if bob_share_id:
|
||||
logger.info(f"Alice deleting Bob's share {bob_share_id}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_share_delete", arguments={"share_id": bob_share_id}
|
||||
)
|
||||
logger.info(f"Alice deleting file {file_path}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": file_path}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that file listing respects share permissions.
|
||||
|
||||
Scenario:
|
||||
1. Alice creates her private file via MCP
|
||||
2. Bob creates his private file via MCP
|
||||
3. Alice creates a file and shares it with Bob via MCP
|
||||
4. Alice can list her own files + shared files
|
||||
5. Bob can list his own files + shared files from Alice
|
||||
"""
|
||||
alice_file = "/alice_private_file.txt"
|
||||
bob_file = "/bob_private_file.txt"
|
||||
shared_file = "/alice_shared_with_bob.txt"
|
||||
|
||||
# Alice creates her private file
|
||||
logger.info(f"Alice creating private file: {alice_file}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": alice_file, "content": "Alice's private file"},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create file: {result.content}"
|
||||
|
||||
# Bob creates his private file
|
||||
logger.info(f"Bob creating private file: {bob_file}")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": bob_file, "content": "Bob's private file"},
|
||||
)
|
||||
assert not result.isError, f"Bob failed to create file: {result.content}"
|
||||
|
||||
# Alice creates a shared file
|
||||
logger.info(f"Alice creating shared file: {shared_file}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": shared_file, "content": "Shared file content"},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create shared file: {result.content}"
|
||||
|
||||
share_id = None
|
||||
|
||||
try:
|
||||
# Alice shares the file with Bob
|
||||
logger.info("Alice sharing file with Bob...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_share_create",
|
||||
arguments={
|
||||
"path": shared_file,
|
||||
"share_with": "bob",
|
||||
"share_type": 0,
|
||||
"permissions": 1,
|
||||
},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create share: {result.content}"
|
||||
share_data = json.loads(result.content[0].text)
|
||||
share_id = share_data["id"]
|
||||
|
||||
# Test: Alice lists files in root
|
||||
logger.info("Alice listing files via MCP...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
file_names = [f["name"] for f in response_data]
|
||||
logger.info(f"Alice can see files: {file_names}")
|
||||
|
||||
# Alice should see her own files
|
||||
# Note: Exact assertions depend on test isolation
|
||||
else:
|
||||
logger.warning(f"Alice could not list files: {result.content}")
|
||||
|
||||
# Test: Bob lists files in root
|
||||
logger.info("Bob listing files via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
file_names = [f["name"] for f in response_data]
|
||||
logger.info(f"Bob can see files: {file_names}")
|
||||
|
||||
# Bob should see his own file, but not Alice's private file
|
||||
# Bob may see shared files in his shared folder or via different path
|
||||
else:
|
||||
logger.warning(f"Bob could not list files: {result.content}")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if share_id:
|
||||
logger.info(f"Alice deleting share {share_id}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_share_delete", arguments={"share_id": share_id}
|
||||
)
|
||||
|
||||
logger.info("Cleaning up Alice's files...")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": alice_file}
|
||||
)
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": shared_file}
|
||||
)
|
||||
|
||||
logger.info("Cleaning up Bob's files...")
|
||||
await bob_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": bob_file}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that folder sharing works correctly.
|
||||
|
||||
Scenario:
|
||||
1. Alice creates a folder via MCP
|
||||
2. Alice creates files in the folder via MCP
|
||||
3. Alice shares the folder with Bob via MCP
|
||||
4. Bob can access files in the shared folder via MCP
|
||||
"""
|
||||
folder_path = "/alice_shared_folder"
|
||||
file_in_folder = f"{folder_path}/document.txt"
|
||||
file_content = "This is a document in Alice's shared folder"
|
||||
|
||||
# Alice creates folder
|
||||
logger.info(f"Alice creating folder: {folder_path}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_create_directory", arguments={"path": folder_path}
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create folder: {result.content}"
|
||||
|
||||
# Alice creates file in folder
|
||||
logger.info(f"Alice creating file in folder: {file_in_folder}")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_webdav_write_file",
|
||||
arguments={"path": file_in_folder, "content": file_content},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create file: {result.content}"
|
||||
|
||||
share_id = None
|
||||
|
||||
try:
|
||||
# Alice shares the folder with Bob
|
||||
logger.info("Alice sharing folder with Bob...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_share_create",
|
||||
arguments={
|
||||
"path": folder_path,
|
||||
"share_with": "bob",
|
||||
"share_type": 0,
|
||||
"permissions": 1,
|
||||
},
|
||||
)
|
||||
assert not result.isError, f"Alice failed to create share: {result.content}"
|
||||
share_data = json.loads(result.content[0].text)
|
||||
share_id = share_data["id"]
|
||||
logger.info(f"Created folder share {share_id}")
|
||||
|
||||
# Test: Bob lists the shared folder
|
||||
logger.info("Bob attempting to list shared folder via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory", arguments={"path": folder_path}
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
logger.info(f"Bob can see {len(response_data)} files in shared folder")
|
||||
|
||||
# Bob should see the file in the shared folder
|
||||
file_names = [f["name"] for f in response_data]
|
||||
assert "document.txt" in file_names, (
|
||||
"Bob should see the file in shared folder"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Bob could not list shared folder: {result.content}")
|
||||
|
||||
# Test: Bob reads the file in the shared folder
|
||||
logger.info("Bob attempting to read file in shared folder via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_webdav_read_file", arguments={"path": file_in_folder}
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
logger.info("Bob successfully read file in shared folder")
|
||||
assert "content" in response_data
|
||||
assert file_content in response_data["content"]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Bob could not read file in shared folder: {result.content}"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup - Alice deletes the share and folder
|
||||
if share_id:
|
||||
logger.info(f"Alice deleting share {share_id}")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_share_delete", arguments={"share_id": share_id}
|
||||
)
|
||||
|
||||
logger.info("Alice cleaning up test folder...")
|
||||
await alice_mcp_client.call_tool(
|
||||
"nc_webdav_delete_resource", arguments={"path": folder_path}
|
||||
)
|
||||
@@ -0,0 +1,260 @@
|
||||
"""
|
||||
Multi-user OAuth tests for Nextcloud Notes permissions.
|
||||
|
||||
Tests verify that the MCP server respects Nextcloud Notes sharing permissions
|
||||
when accessed via OAuth authentication with different users.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_read_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that shared notes respect read permissions.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a note as alice
|
||||
2. Admin shares the note with bob (read-only)
|
||||
3. Bob can read the note via MCP tools
|
||||
4. Diana cannot access the note (no share)
|
||||
"""
|
||||
# Create a note as alice (using admin client to set up data)
|
||||
note_title = "Alice's Shared Note - Read Test"
|
||||
note_content = "This note is shared with Bob for reading only."
|
||||
note_category = "SharedNotes"
|
||||
|
||||
logger.info("Creating note as alice...")
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content=note_content, category=note_category
|
||||
)
|
||||
note_id = created_note.get("id")
|
||||
|
||||
try:
|
||||
# TODO: Share the note with bob (read-only)
|
||||
# Note: Nextcloud Notes API doesn't have direct sharing endpoints
|
||||
# Sharing is typically done at the folder level via WebDAV
|
||||
# For now, this test documents the expected behavior
|
||||
|
||||
# Test: Bob searches for notes via MCP
|
||||
logger.info("Bob searching for notes via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Bob's search failed: {result.content}"
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# Bob should see the shared note in search results
|
||||
# (assuming proper share setup)
|
||||
assert "results" in response_data
|
||||
logger.info(f"Bob found {len(response_data['results'])} notes")
|
||||
|
||||
# Test: Diana searches for the same note
|
||||
logger.info("Diana searching for notes via MCP...")
|
||||
result = await diana_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": "Alice's Shared"}
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# Diana should NOT see the note (no share)
|
||||
assert "results" in response_data
|
||||
shared_note_ids = [
|
||||
n["id"] for n in response_data["results"] if n["id"] == note_id
|
||||
]
|
||||
assert len(shared_note_ids) == 0, "Diana should not see unshared note"
|
||||
logger.info("Diana correctly cannot see unshared note")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
logger.info(f"Cleaning up note {note_id}")
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_notes_share_write_permissions(
|
||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
"""
|
||||
Test that shared notes respect write permissions.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a note as alice
|
||||
2. Admin shares the note with charlie (edit permission)
|
||||
3. Admin shares the note with bob (read-only)
|
||||
4. Charlie can edit the note via MCP tools
|
||||
5. Bob cannot edit the note
|
||||
"""
|
||||
# Create a note as alice
|
||||
note_title = "Alice's Shared Note - Write Test"
|
||||
note_content = "This note is shared with Charlie for editing."
|
||||
note_category = "SharedNotes"
|
||||
|
||||
logger.info("Creating note as alice...")
|
||||
created_note = await nc_client.notes.create_note(
|
||||
title=note_title, content=note_content, category=note_category
|
||||
)
|
||||
note_id = created_note.get("id")
|
||||
|
||||
try:
|
||||
# TODO: Share the note with charlie (edit permission) and bob (read-only)
|
||||
# Note: Nextcloud Notes sharing is folder-based
|
||||
|
||||
# Test: Charlie can append content to the note
|
||||
logger.info("Charlie attempting to append content via MCP...")
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"nc_notes_append_content",
|
||||
arguments={
|
||||
"note_id": note_id,
|
||||
"content": "\n\nCharlie added this content.",
|
||||
},
|
||||
)
|
||||
|
||||
# If sharing is properly configured, Charlie should succeed
|
||||
# Without proper sharing setup, this will fail
|
||||
logger.info(f"Charlie's append result: isError={result.isError}")
|
||||
if not result.isError:
|
||||
logger.info("Charlie successfully appended content (shares configured)")
|
||||
else:
|
||||
logger.warning("Charlie could not append (shares not yet configured)")
|
||||
|
||||
# Test: Bob attempts to append content (should fail)
|
||||
logger.info("Bob attempting to append content via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_notes_append_content",
|
||||
arguments={"note_id": note_id, "content": "\n\nBob tried to add this."},
|
||||
)
|
||||
|
||||
# Bob should fail (read-only access)
|
||||
logger.info(f"Bob's append result: isError={result.isError}")
|
||||
if result.isError:
|
||||
logger.info("Bob correctly denied write access")
|
||||
else:
|
||||
logger.warning("Bob unexpectedly succeeded (permissions issue?)")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
logger.info(f"Cleaning up note {note_id}")
|
||||
await nc_client.notes.delete_note(note_id)
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Scenario:
|
||||
1. Admin creates a note as alice (not shared)
|
||||
2. Admin creates a note as bob (not shared)
|
||||
3. Alice can only see her own note
|
||||
4. Bob can only see his own note
|
||||
"""
|
||||
# Create alice's note
|
||||
logger.info("Creating alice's private note...")
|
||||
alice_note = await nc_client.notes.create_note(
|
||||
title="Alice's Private Note",
|
||||
content="This is Alice's private content.",
|
||||
category="AlicePrivate",
|
||||
)
|
||||
alice_note_id = alice_note.get("id")
|
||||
|
||||
# Create bob's note
|
||||
logger.info("Creating bob's private note...")
|
||||
bob_note = await nc_client.notes.create_note(
|
||||
title="Bob's Private Note",
|
||||
content="This is Bob's private content.",
|
||||
category="BobPrivate",
|
||||
)
|
||||
bob_note_id = bob_note.get("id")
|
||||
|
||||
try:
|
||||
# Test: Alice searches all notes
|
||||
logger.info("Alice searching all notes via MCP...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
response_data = json.loads(result.content[0].text)
|
||||
alice_notes = response_data.get("results", [])
|
||||
alice_note_ids = [n["id"] for n in alice_notes]
|
||||
|
||||
logger.info(f"Alice can see {len(alice_notes)} notes")
|
||||
# Alice should NOT see Bob's note
|
||||
assert bob_note_id not in alice_note_ids, (
|
||||
"Alice should not see Bob's private note"
|
||||
)
|
||||
|
||||
# Test: Bob searches all notes
|
||||
logger.info("Bob searching all notes via MCP...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
response_data = json.loads(result.content[0].text)
|
||||
bob_notes = response_data.get("results", [])
|
||||
bob_note_ids = [n["id"] for n in bob_notes]
|
||||
|
||||
logger.info(f"Bob can see {len(bob_notes)} notes")
|
||||
# Bob should NOT see Alice's note
|
||||
assert alice_note_id not in bob_note_ids, (
|
||||
"Bob should not see Alice's private note"
|
||||
)
|
||||
|
||||
logger.info("User isolation test passed: users can only see their own notes")
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
logger.info("Cleaning up test notes...")
|
||||
await nc_client.notes.delete_note(alice_note_id)
|
||||
await nc_client.notes.delete_note(bob_note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oauth_mcp_clients_initialized(
|
||||
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
||||
):
|
||||
"""
|
||||
Smoke test to verify all OAuth MCP clients are properly initialized.
|
||||
"""
|
||||
logger.info("Testing alice_mcp_client initialization...")
|
||||
result = await alice_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result.isError is False, f"Alice MCP client failed: {result.content}"
|
||||
logger.info("Alice MCP client working")
|
||||
|
||||
logger.info("Testing bob_mcp_client initialization...")
|
||||
result = await bob_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result.isError is False, f"Bob MCP client failed: {result.content}"
|
||||
logger.info("Bob MCP client working")
|
||||
|
||||
logger.info("Testing charlie_mcp_client initialization...")
|
||||
result = await charlie_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result.isError is False, f"Charlie MCP client failed: {result.content}"
|
||||
logger.info("Charlie MCP client working")
|
||||
|
||||
logger.info("Testing diana_mcp_client initialization...")
|
||||
result = await diana_mcp_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
assert result.isError is False, f"Diana MCP client failed: {result.content}"
|
||||
logger.info("Diana MCP client working")
|
||||
|
||||
logger.info("All OAuth MCP clients successfully initialized!")
|
||||
@@ -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
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"http://localhost:8001/.well-known/oauth-protected-resource"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
prm_data = response.json()
|
||||
assert prm_data["resource"] == "http://localhost:8001"
|
||||
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"])
|
||||
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
|
||||
@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
|
||||
|
||||
# Create user
|
||||
await nc_client.users.create_user(**user_config)
|
||||
|
||||
# Verify user exists
|
||||
users = await nc_client.users.search_users(search=user_config["userid"])
|
||||
assert user_config["userid"] in users
|
||||
|
||||
user_details = await nc_client.users.get_user_details(user_config["userid"])
|
||||
assert user_details.id == user_config["userid"]
|
||||
assert user_details.displayname == user_config["display_name"]
|
||||
assert user_details.email == user_config["email"]
|
||||
|
||||
# Test deletion explicitly as part of test functionality
|
||||
await nc_client.users.delete_user(user_config["userid"])
|
||||
|
||||
# Verify user is deleted
|
||||
users = await nc_client.users.search_users(search=user_config["userid"])
|
||||
assert user_config["userid"] not in users
|
||||
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
"""Test updating user fields."""
|
||||
user_config = test_user
|
||||
|
||||
await nc_client.users.create_user(**user_config)
|
||||
|
||||
new_email = f"new.{user_config['email']}"
|
||||
await nc_client.users.update_user_field(user_config["userid"], "email", new_email)
|
||||
|
||||
user_details = await nc_client.users.get_user_details(user_config["userid"])
|
||||
assert user_details.email == new_email
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@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
|
||||
userid = user_config["userid"]
|
||||
|
||||
# Verify user is in group
|
||||
groups = await nc_client.users.get_user_groups(userid)
|
||||
assert groupid in groups
|
||||
|
||||
# Remove user from group
|
||||
await nc_client.users.remove_user_from_group(userid, groupid)
|
||||
groups = await nc_client.users.get_user_groups(userid)
|
||||
assert groupid not in groups
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
|
||||
"""Test promoting and demoting subadmins."""
|
||||
user_config = test_user
|
||||
groupid = test_group
|
||||
userid = user_config["userid"]
|
||||
|
||||
await nc_client.users.create_user(**user_config)
|
||||
|
||||
# Promote to subadmin
|
||||
await nc_client.users.promote_user_to_subadmin(userid, groupid)
|
||||
subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
|
||||
assert groupid in subadmin_groups
|
||||
|
||||
# Demote from subadmin
|
||||
await nc_client.users.demote_user_from_subadmin(userid, groupid)
|
||||
subadmin_groups = await nc_client.users.get_user_subadmin_groups(userid)
|
||||
assert groupid not in subadmin_groups
|
||||
# Fixtures will handle cleanup
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
"""Test disabling and enabling users."""
|
||||
user_config = test_user
|
||||
userid = user_config["userid"]
|
||||
|
||||
await nc_client.users.create_user(**user_config)
|
||||
|
||||
# Disable user
|
||||
await nc_client.users.disable_user(userid)
|
||||
user_details = await nc_client.users.get_user_details(userid)
|
||||
assert not user_details.enabled
|
||||
|
||||
# Enable user
|
||||
await nc_client.users.enable_user(userid)
|
||||
user_details = await nc_client.users.get_user_details(userid)
|
||||
assert user_details.enabled
|
||||
# Fixture will handle cleanup
|
||||
|
||||
|
||||
@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
|
||||
assert "email" in editable_fields
|
||||
@@ -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}"
|
||||
@@ -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"]
|
||||
+1
Submodule third_party/oidc added at f7f80b72d5
Reference in New Issue
Block a user