Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e0ab127a | |||
| 1117a83a52 | |||
| 01b43c96ba | |||
| c9db6afb59 | |||
| 50b69a2531 | |||
| 8e0a4d8ce5 | |||
| 72fce189d2 | |||
| 1e877f17f7 | |||
| 50a824155c | |||
| 0df9e41332 | |||
| 13f76a7734 | |||
| 3baf10662f | |||
| 81ca799410 | |||
| 2f1bd1bbe9 | |||
| d452684535 | |||
| bfbaed9a66 | |||
| ff32149220 | |||
| d55e5708c7 | |||
| d4ee5a74c2 | |||
| 261749fcdc | |||
| bdb0e17401 | |||
| 8942f3119c | |||
| 3863cca2ed | |||
| a93e7a1e3b | |||
| f2d2dd8068 | |||
| d915efd3f6 | |||
| 053cf7798b | |||
| 87c6f077f3 | |||
| 38e12db46a | |||
| 1a7ce5b7a7 | |||
| 737780b417 | |||
| b4039e2e40 | |||
| 54e975198f | |||
| e9a16c43b5 | |||
| e48f5f3f30 | |||
| 3ebc468a09 | |||
| 1aecb099e6 | |||
| 2c35e07675 | |||
| 5cfdff0faf | |||
| eb7e15cac0 | |||
| 894723c525 | |||
| 8a3269f366 | |||
| c069d78f80 | |||
| e3436fecc0 | |||
| e3feb3eb2f | |||
| eedaa2e3f1 | |||
| d517fe09d8 | |||
| 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 | |||
| f0c03ceede | |||
| f51d3a2101 | |||
| 3f04449a86 | |||
| 144a54c1ad |
@@ -0,0 +1,33 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
pypi:
|
||||
name: Publish to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Environment and permissions trusted publishing.
|
||||
environment:
|
||||
# Create this environment in the GitHub repository under Settings -> Environments
|
||||
name: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
run: uv build
|
||||
- name: Smoke test (wheel)
|
||||
run: uv run --isolated --no-project --with dist/*.whl nextcloud-mcp-server --help
|
||||
- name: Smoke test (source distribution)
|
||||
run: uv run --isolated --no-project --with dist/*.tar.gz nextcloud-mcp-server --help
|
||||
- name: Publish
|
||||
run: uv publish
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@3259c6206f993105e3a61b142c2d97bf4b9ef83d # v7.1.0
|
||||
uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # v7.1.1
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -25,6 +25,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
|
||||
- name: Install OIDC app composer dependencies
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
@@ -33,7 +52,7 @@ 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: |
|
||||
@@ -62,4 +81,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --log-level=INFO
|
||||
uv run pytest -v --log-cli-level=INFO
|
||||
|
||||
@@ -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,78 @@
|
||||
## v0.20.0 (2025-10-24)
|
||||
|
||||
### Feat
|
||||
|
||||
- **auth**: Add support for client registration deletion
|
||||
- Split read/write scopes into app:read/write scopes
|
||||
|
||||
### Fix
|
||||
|
||||
- Add support for RFC 7592 client registration and deletion
|
||||
- Update webdav models for proper serialization
|
||||
|
||||
## v0.19.1 (2025-10-24)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||
|
||||
## v0.19.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Enable token introspection for opaque tokens
|
||||
|
||||
### Fix
|
||||
|
||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
|
||||
## v0.18.0 (2025-10-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||
- Initialize JWT-scoped tools
|
||||
|
||||
### Fix
|
||||
|
||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||
|
||||
### Refactor
|
||||
|
||||
- Update JWT client to use DCR, re-enable tool filtering
|
||||
|
||||
## v0.17.1 (2025-10-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- **caldav**: Fix caldav search() due to missing todos
|
||||
|
||||
## v0.17.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **caldav**: Add support for tasks
|
||||
|
||||
### Fix
|
||||
|
||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Migrate from internal CalendarClient to caldav library
|
||||
|
||||
## v0.16.0 (2025-10-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- **webdav**: Add search and list favorite response tools
|
||||
|
||||
### Perf
|
||||
|
||||
- **notes**: Improve notes search performance using async iterators
|
||||
|
||||
## v0.15.2 (2025-10-17)
|
||||
|
||||
### Refactor
|
||||
|
||||
@@ -5,18 +5,37 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
## Development Commands
|
||||
|
||||
### Testing
|
||||
|
||||
The test suite is organized in layers for fast feedback:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
# FAST FEEDBACK (recommended for development)
|
||||
# Unit tests only - ~5 seconds
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Smoke tests - critical path validation - ~30-60 seconds
|
||||
uv run pytest -m smoke -v
|
||||
|
||||
# INTEGRATION TESTS
|
||||
# Integration tests without OAuth - ~2-3 minutes
|
||||
uv run pytest -m "integration and not oauth" -v
|
||||
|
||||
# Full test suite - ~4-5 minutes
|
||||
uv run pytest
|
||||
|
||||
# Run integration tests only
|
||||
uv run pytest -m integration
|
||||
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||
uv run pytest -m oauth -v
|
||||
|
||||
# COVERAGE
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov
|
||||
|
||||
# LEGACY COMMANDS (still work)
|
||||
# Run all integration tests
|
||||
uv run pytest -m integration -v
|
||||
|
||||
# Skip integration tests
|
||||
uv run pytest -m "not integration"
|
||||
uv run pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
### Load Testing
|
||||
@@ -89,16 +108,18 @@ docker-compose up
|
||||
# For basic auth changes (most common) - uses admin credentials
|
||||
docker-compose up --build -d mcp
|
||||
|
||||
# For OAuth changes - uses OAuth authentication flow
|
||||
# For OAuth changes - uses OAuth authentication with JWT tokens
|
||||
docker-compose up --build -d mcp-oauth
|
||||
|
||||
# Build Docker image
|
||||
docker build -t nextcloud-mcp-server .
|
||||
```
|
||||
|
||||
**Important: Two MCP Server Containers**
|
||||
**Important: MCP Server Containers**
|
||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
|
||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
|
||||
- JWT tokens are used for testing (faster validation, scopes embedded in token)
|
||||
- The server can handle both JWT and opaque tokens via the token verifier
|
||||
|
||||
### Environment Setup
|
||||
```bash
|
||||
@@ -109,6 +130,36 @@ uv sync
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
### Database Inspection
|
||||
|
||||
**Docker Compose Database Credentials:**
|
||||
- Root user: `root` / password: `password`
|
||||
- App user: `nextcloud` / password: `password`
|
||||
- Database: `nextcloud`
|
||||
|
||||
**Common Database Commands:**
|
||||
```bash
|
||||
# Connect to database as root (most common for inspection)
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
|
||||
# Check OAuth clients
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# Check OAuth client scopes
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
|
||||
|
||||
# Check OAuth access tokens
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
**Important Tables:**
|
||||
- `oc_oidc_clients` - OAuth client registrations (DCR clients)
|
||||
- `oc_oidc_client_scopes` - Client allowed scopes
|
||||
- `oc_oidc_access_tokens` - Issued access tokens
|
||||
- `oc_oidc_authorization_codes` - Authorization codes
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs for each client
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern:
|
||||
@@ -136,7 +187,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
|
||||
@@ -169,9 +230,37 @@ FastMCP serialization issue: raw lists get mangled into dicts with numeric strin
|
||||
|
||||
### Testing Structure
|
||||
|
||||
- **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
|
||||
The test suite follows a layered architecture for fast feedback:
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast unit tests (~5s total)
|
||||
│ ├── test_scope_decorator.py
|
||||
│ └── test_response_models.py
|
||||
├── smoke/ # Critical path tests (~30-60s)
|
||||
│ └── test_smoke.py
|
||||
├── integration/
|
||||
│ ├── client/ # Direct API layer tests
|
||||
│ │ ├── notes/
|
||||
│ │ ├── calendar/
|
||||
│ │ └── ...
|
||||
│ └── server/ # MCP tool layer tests
|
||||
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
|
||||
│ │ ├── test_oauth_core.py
|
||||
│ │ ├── test_scope_authorization.py
|
||||
│ │ └── ...
|
||||
│ ├── test_mcp.py
|
||||
│ └── ...
|
||||
└── load/ # Performance tests
|
||||
```
|
||||
|
||||
**Test Markers:**
|
||||
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
|
||||
- `@pytest.mark.integration` - Integration tests requiring Docker containers
|
||||
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
|
||||
- `@pytest.mark.smoke` - Critical path smoke tests
|
||||
|
||||
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||
@@ -196,16 +285,84 @@ FastMCP serialization issue: raw lists get mangled into dicts with numeric strin
|
||||
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
|
||||
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
|
||||
|
||||
#### Writing Mocked Unit Tests
|
||||
|
||||
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
|
||||
|
||||
**Pattern:**
|
||||
```python
|
||||
import httpx
|
||||
import pytest
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.conftest import create_mock_note_response
|
||||
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response using helper functions
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
```
|
||||
|
||||
**Mock Response Helpers in `tests/conftest.py`:**
|
||||
- `create_mock_response()` - Generic HTTP response builder
|
||||
- `create_mock_note_response()` - Pre-configured note response
|
||||
- `create_mock_error_response()` - Error responses (404, 412, etc.)
|
||||
|
||||
**Benefits:**
|
||||
- ⚡ Fast execution (~0.1s vs minutes for integration tests)
|
||||
- 🔒 No Docker dependency
|
||||
- 🎯 Tests focus on response parsing logic
|
||||
- ♻️ Repeatable and deterministic
|
||||
|
||||
**When to use:**
|
||||
- Testing client methods that parse JSON responses
|
||||
- Testing error handling (404, 412, etc.)
|
||||
- Testing request parameter building
|
||||
|
||||
**When NOT to use (keep as integration tests):**
|
||||
- Complex protocol interactions (CalDAV, CardDAV, WebDAV)
|
||||
- Multi-component workflows (Notes + WebDAV attachments)
|
||||
- OAuth flows
|
||||
- End-to-end MCP tool testing
|
||||
|
||||
**Reference Implementation:**
|
||||
- See `tests/client/notes/test_notes_api.py` for complete examples
|
||||
- Mark unit tests with `pytestmark = pytest.mark.unit`
|
||||
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
|
||||
|
||||
#### OAuth/OIDC Testing
|
||||
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
|
||||
|
||||
**OAuth Testing Setup:**
|
||||
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
|
||||
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
|
||||
- Stored in `.nextcloud_oauth_shared_test_client.json`
|
||||
- Matches production MCP server behavior
|
||||
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
|
||||
- Matches production MCP server behavior (one client, multiple user tokens)
|
||||
- Each user gets their own unique access token
|
||||
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
|
||||
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
|
||||
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
|
||||
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
|
||||
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
|
||||
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
|
||||
@@ -216,13 +373,13 @@ OAuth integration tests use **automated Playwright browser automation** to compl
|
||||
**Example Commands:**
|
||||
```bash
|
||||
# Run all OAuth tests with Playwright automation using Firefox
|
||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
||||
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||
|
||||
# Run specific tests with visible browser for debugging
|
||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
||||
# Run specific OAuth test file with visible browser for debugging
|
||||
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||
|
||||
# Run with Chromium (default)
|
||||
uv run pytest tests/server/test_oauth*.py -v
|
||||
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||
uv run pytest -m oauth -v
|
||||
```
|
||||
|
||||
**Test Environment:**
|
||||
@@ -231,7 +388,6 @@ uv run pytest tests/server/test_oauth*.py -v
|
||||
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
|
||||
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
|
||||
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
|
||||
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
|
||||
|
||||
**CI/CD Notes:**
|
||||
- Playwright tests run in CI/CD environments
|
||||
@@ -242,3 +398,15 @@ uv run pytest tests/server/test_oauth*.py -v
|
||||
- **`pyproject.toml`** - Python project configuration using uv for dependency management
|
||||
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
|
||||
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
|
||||
|
||||
## Integration testing with docker
|
||||
|
||||
### Nextcloud
|
||||
|
||||
- The `app` container is running nextcloud.
|
||||
- Use `docker compose exec app php occ ...` to get a list of available commands
|
||||
|
||||
### Mariadb
|
||||
|
||||
- The `db` container is running mariadb
|
||||
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.4-python3.11-alpine@sha256:a77a52d51f4382049d92547279040c1154e296bf2da8a380da90e28587195789
|
||||
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
|
||||
|
||||
|
||||
@@ -7,22 +7,35 @@
|
||||
The Nextcloud MCP (Model Context Protocol) server allows Large Language Models like Claude, GPT, and Gemini to interact with your Nextcloud data through a secure API. Create notes, manage calendars, organize contacts, work with files, and more - all through natural language.
|
||||
|
||||
> [!NOTE]
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also exposes an MCP server endpoint for external LLMs. This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. See our [detailed comparison](docs/comparison-context-agent.md) to understand which approach fits your use case.
|
||||
> **Nextcloud has two ways to enable AI access:** Nextcloud provides [Context Agent](https://github.com/nextcloud/context_agent), an AI agent backend that powers the [Assistant](https://github.com/nextcloud/assistant) app and allows AI to interact with Nextcloud apps like Calendar, Talk, and Contacts. Context Agent runs as an ExApp inside Nextcloud and also _[exposes an MCP server](https://docs.nextcloud.com/server/stable/admin_manual/ai/app_context_agent.html#using-nextcloud-mcp-server)_ for external MCP clients.
|
||||
>
|
||||
> This project (Nextcloud MCP Server) is a **dedicated standalone MCP server** designed specifically for external MCP clients like Claude Code and IDEs, with deep CRUD operations and OAuth support. It does not require any additional AI-features to be enabled in Nextcloud beyond the apps that you intend to interact with.
|
||||
|
||||
## Features
|
||||
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
|
||||
|
||||
### Supported Nextcloud Apps
|
||||
| Aspect | **Nextcloud MCP Server**<br/>(This Project) | **Nextcloud AI Stack**<br/>(Assistant + Context Agent) |
|
||||
|--------|---------------------------------------------|--------------------------------------------------------|
|
||||
| **Purpose** | External MCP client access to Nextcloud | AI assistance within Nextcloud UI |
|
||||
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
|
||||
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
|
||||
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
|
||||
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||
| **Deck** | ✅ Full project management (15 tools) | ✅ Basic board/card ops (2 tools) |
|
||||
| **Tables** | ✅ Row operations (5 tools) | ❌ Not implemented |
|
||||
| **Cookbook** | ✅ Full recipe management (13 tools) | ❌ Not implemented |
|
||||
| **Talk** | ❌ Not implemented | ✅ Messages, conversations (4 tools) |
|
||||
| **Mail** | ❌ Not implemented | ✅ Send email (2 tools) |
|
||||
| **AI Features** | ❌ Not implemented | ✅ Image gen, transcription, doc gen (4 tools) |
|
||||
| **Web/Maps** | ❌ Not implemented | ✅ Search, weather, transit (5 tools) |
|
||||
| **MCP Resources** | ✅ Structured data URIs | ❌ Not supported |
|
||||
| **External MCP** | ❌ Pure server | ✅ Consumes external MCP servers |
|
||||
| **Safety Model** | Client-controlled | Built-in safe/dangerous distinction |
|
||||
| **Best For** | • Deep CRUD operations<br/>• External integrations<br/>• OAuth security<br/>• IDE/editor integration | • AI-driven actions in Nextcloud UI<br/>• Multi-service orchestration<br/>• User task automation<br/>• MCP aggregation hub |
|
||||
|
||||
| App | Support | Features |
|
||||
|-----|---------|----------|
|
||||
| **Notes** | ✅ Full | Create, read, update, delete, search notes. Handle attachments. |
|
||||
| **Calendar** | ✅ Full | Manage events, recurring events, reminders, attendees via CalDAV. |
|
||||
| **Contacts** | ✅ Full | CRUD operations for contacts and address books via CardDAV. |
|
||||
| **Cookbook** | ✅ Full | Manage recipes with schema.org metadata. Import from URLs, search, categorize. |
|
||||
| **Files (WebDAV)** | ✅ Full | Complete file system access - browse, read, write, organize files. |
|
||||
| **Deck** | ✅ Full | Project management - boards, stacks, cards, labels, assignments. |
|
||||
| **Tables** | ⚠️ Partial | Row-level operations. Table management not yet supported. |
|
||||
| **Tasks** | ❌ Planned | [Issue #73](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/73) |
|
||||
See our [detailed comparison](docs/comparison-context-agent.md) for architecture diagrams, workflow examples, and guidance on when to use each approach.
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -30,14 +43,15 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
||||
|
||||
| Mode | Security | Best For |
|
||||
|------|----------|----------|
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patches) |
|
||||
| **OAuth2/OIDC** ⚠️ **Experimental** | 🔒 High | Testing, evaluation (requires patch for app-specific APIs) |
|
||||
| **Basic Auth** ✅ | Lower | Development, testing, production |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **OAuth is experimental** and requires manual patches to upstream Nextcloud apps. Specifically:
|
||||
> **OAuth is experimental** and requires a manual patch to the `user_oidc` app for full functionality:
|
||||
> - **Required patch**: `user_oidc` app needs modifications for Bearer token support ([issue #1221](https://github.com/nextcloud/user_oidc/issues/1221))
|
||||
> - **Impact**: Without the patch, most app-specific APIs (Notes, Calendar, Contacts, Deck, etc.) will fail with 401 errors
|
||||
> - **Production use**: Wait for upstream patches to be merged into official releases
|
||||
> - **What works without patches**: OAuth flow, PKCE support (with `oidc` v1.10.0+), OCS APIs
|
||||
> - **Production use**: Wait for upstream patch to be merged into official releases
|
||||
>
|
||||
> See [OAuth Upstream Status](docs/oauth-upstream-status.md) for detailed information on required patches and workarounds.
|
||||
|
||||
@@ -92,10 +106,10 @@ See [Configuration Guide](docs/configuration.md) for all options.
|
||||
3. Start the server
|
||||
|
||||
**OAuth Setup (experimental):**
|
||||
1. Install Nextcloud OIDC apps (`oidc` + `user_oidc`)
|
||||
2. **Apply required patches** to `user_oidc` app (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration
|
||||
4. Configure Bearer token validation
|
||||
1. Install Nextcloud OIDC apps (`oidc` v1.10.0+ + `user_oidc`)
|
||||
2. **Apply required patch** to `user_oidc` app for Bearer token support (see [OAuth Upstream Status](docs/oauth-upstream-status.md))
|
||||
3. Enable dynamic client registration or create an OIDC client with id & secret
|
||||
4. Configure Bearer token validation in `user_oidc`
|
||||
5. Start the server
|
||||
|
||||
See [OAuth Quick Start](docs/quickstart-oauth.md) for 5-minute setup or [OAuth Setup Guide](docs/oauth-setup.md) for detailed instructions.
|
||||
@@ -168,13 +182,36 @@ Or connect from:
|
||||
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
||||
|
||||
### Tools
|
||||
Tools enable AI assistants to perform actions:
|
||||
|
||||
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
|
||||
|
||||
#### Available Tool Categories
|
||||
|
||||
| App | Tools | Read Scope | Write Scope | Operations |
|
||||
|-----|-------|-----------|-------------|------------|
|
||||
| **Notes** | 7 | `mcp:notes:read` | `mcp:notes:write` | Create, read, update, delete, search notes |
|
||||
| **Calendar** | 20+ | `mcp:calendar:read` | `mcp:calendar:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||
| **Contacts** | 8 | `mcp:contacts:read` | `mcp:contacts:write` | Create, read, update, delete contacts and address books |
|
||||
| **Files (WebDAV)** | 12 | `mcp:files:read` | `mcp:files:write` | List, read, upload, delete, move files and folders |
|
||||
| **Deck** | 15 | `mcp:deck:read` | `mcp:deck:write` | Boards, stacks, cards, labels, assignments |
|
||||
| **Cookbook** | 13 | `mcp:cookbook:read` | `mcp:cookbook:write` | Recipes, import from URLs, search, categories |
|
||||
| **Tables** | 5 | `mcp:tables:read` | `mcp:tables:write` | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | `mcp:sharing:read` | `mcp:sharing:write` | Create, manage, delete shares |
|
||||
|
||||
**Example Tools:**
|
||||
- `nc_notes_create_note` - Create a new note
|
||||
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||
- `deck_create_card` - Create a Deck card
|
||||
- `nc_calendar_create_event` - Create a calendar event
|
||||
- `nc_calendar_create_todo` - Create a CalDAV task/todo
|
||||
- `nc_contacts_create_contact` - Create a contact
|
||||
- And many more...
|
||||
- `nc_webdav_upload_file` - Upload a file to Nextcloud
|
||||
- And 80+ more...
|
||||
|
||||
> [!TIP]
|
||||
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `mcp:notes:read` and `mcp:notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
|
||||
>
|
||||
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
|
||||
|
||||
### Resources
|
||||
Resources provide read-only access to Nextcloud data:
|
||||
|
||||
@@ -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
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC app for testing..."
|
||||
|
||||
# Check if development OIDC app is mounted at /opt/apps/oidc
|
||||
if [ -d /opt/apps/oidc ]; then
|
||||
echo "Development OIDC app found at /opt/apps/oidc"
|
||||
|
||||
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/oidc ]; then
|
||||
echo "Removing existing OIDC in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/oidc
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
|
||||
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
|
||||
|
||||
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
elif [ -d /var/www/html/custom_apps/oidc ]; then
|
||||
echo "OIDC app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable oidc
|
||||
else
|
||||
echo "OIDC app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install oidc
|
||||
php /var/www/html/occ app:enable oidc
|
||||
fi
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring user_oidc app for testing..."
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring OIDC apps for testing..."
|
||||
|
||||
# Enable the OIDC Identity Provider app
|
||||
php /var/www/html/occ app:enable oidc
|
||||
|
||||
# Enable the user_oidc app (OIDC client for bearer token validation)
|
||||
php /var/www/html/occ app:enable user_oidc
|
||||
|
||||
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
|
||||
patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /docker-entrypoint-hooks.d/post-installation/0002-Add-PKCE-code-challenge-methods-to-discovery-documen.patch
|
||||
|
||||
# Configure OIDC Identity Provider with dynamic client registration enabled
|
||||
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
|
||||
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
|
||||
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
|
||||
|
||||
echo "OIDC apps installed and configured successfully"
|
||||
+20
-5
@@ -14,16 +14,26 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:59b6e694653476de2c992937ebe1c64182af4728e54bb49e9b7a6c26614d8933
|
||||
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
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
volumes:
|
||||
- nextcloud:/var/www/html
|
||||
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party/oidc:/opt/apps/oidc:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -32,6 +42,7 @@ services:
|
||||
- MYSQL_DATABASE=nextcloud
|
||||
- MYSQL_USER=nextcloud
|
||||
- MYSQL_HOST=db
|
||||
- REDIS_HOST=redis
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:61e01287e546aac28a3f56839c136b31f590273f3b41187a36f46f6a03bbfe22
|
||||
@@ -55,7 +66,7 @@ services:
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
restart: always
|
||||
depends_on:
|
||||
- app
|
||||
@@ -63,9 +74,13 @@ services:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
|
||||
# No USERNAME/PASSWORD - will use OAuth
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||
# Client credentials will be registered and stored in volume on first startup
|
||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
# JWT OAuth Reference - Nextcloud MCP Server
|
||||
|
||||
**Last Updated:** 2025-10-23
|
||||
**Status:** Production Ready
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
|
||||
- [Scope-Based Authorization](#scope-based-authorization)
|
||||
- [Configuration](#configuration)
|
||||
- [Architecture](#architecture)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Production Deployment](#production-deployment)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
|
||||
|
||||
- **Faster validation** - No HTTP call needed for token verification
|
||||
- **Direct scope extraction** - Scopes embedded in token claims
|
||||
- **Dynamic tool filtering** - Users only see tools they have permission to use
|
||||
- **Signature verification** - Cryptographic validation using JWKS
|
||||
|
||||
### Key Features
|
||||
|
||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||
- ✅ **Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
|
||||
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||
- ✅ **Backward Compatible** - BasicAuth mode bypasses all scope checks
|
||||
|
||||
### Supported Scopes
|
||||
|
||||
| Scope | Description | Tool Count |
|
||||
|-------|-------------|------------|
|
||||
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
|
||||
|
||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||
|
||||
---
|
||||
|
||||
## JWT vs Opaque Tokens
|
||||
|
||||
The Nextcloud OIDC app supports two token formats, configured per-client:
|
||||
|
||||
### JWT Tokens (Recommended)
|
||||
|
||||
**Advantages:**
|
||||
- ✅ Fast validation - JWT signature verified locally using JWKS
|
||||
- ✅ Direct scope extraction from `scope` claim in payload
|
||||
- ✅ Standard approach (RFC 9068)
|
||||
- ✅ No additional HTTP calls for validation
|
||||
|
||||
**Disadvantages:**
|
||||
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
|
||||
- ⚠️ Token payload visible to client (not an issue for access tokens)
|
||||
|
||||
**Token Structure:**
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"typ": "at+JWT",
|
||||
"alg": "RS256",
|
||||
"kid": "..."
|
||||
},
|
||||
"payload": {
|
||||
"iss": "http://localhost:8080",
|
||||
"sub": "admin",
|
||||
"aud": "client_id",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567890,
|
||||
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
||||
"client_id": "...",
|
||||
"jti": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Opaque Tokens
|
||||
|
||||
**Advantages:**
|
||||
- ✅ Smaller size (72 characters)
|
||||
- ✅ No payload visible to client
|
||||
- ✅ Direct scope access via introspection endpoint (RFC 7662)
|
||||
|
||||
**Disadvantages:**
|
||||
- ❌ Higher latency - Requires HTTP call to introspection endpoint
|
||||
- ❌ Slower than JWT signature verification (network roundtrip)
|
||||
|
||||
**Validation Method:**
|
||||
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
|
||||
- Token active status
|
||||
- Scope claim (direct access, no inference needed)
|
||||
- User information (`sub`, `username`)
|
||||
- Token metadata (`exp`, `iat`, `client_id`)
|
||||
|
||||
Falls back to userinfo endpoint only if introspection is unavailable.
|
||||
|
||||
**When to Use:**
|
||||
- Use **JWT tokens** for production (better performance, no HTTP call)
|
||||
- Use **opaque tokens** for compatibility with clients that don't support JWT
|
||||
|
||||
---
|
||||
|
||||
## Scope-Based Authorization
|
||||
|
||||
### Scope Definitions
|
||||
|
||||
The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
| Scope | Operations | Examples |
|
||||
|-------|------------|----------|
|
||||
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
|
||||
### Standard OIDC Scopes
|
||||
|
||||
| Scope | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| `openid` | OIDC authentication | Yes |
|
||||
| `profile` | User profile information | Recommended |
|
||||
| `email` | Email address | Recommended |
|
||||
|
||||
### Recommended Configurations
|
||||
|
||||
**Full Access:**
|
||||
```
|
||||
openid profile email mcp:notes:read mcp:notes:write
|
||||
```
|
||||
|
||||
**Read-Only:**
|
||||
```
|
||||
openid profile email mcp:notes:read
|
||||
```
|
||||
|
||||
**No Custom Scopes (OIDC only):**
|
||||
```
|
||||
openid profile email
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
All 90 MCP tools are decorated with scope requirements:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("mcp:notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||
"""Get a note by ID (requires mcp:notes:read scope)"""
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("mcp:notes:write")
|
||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||
"""Create a note (requires mcp:notes:write scope)"""
|
||||
...
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
||||
- ✅ 90/90 tools covered (100%)
|
||||
|
||||
### Dynamic Tool Filtering
|
||||
|
||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||
|
||||
**Token with `mcp:notes:read` only:**
|
||||
- `list_tools()` returns 36 read-only tools
|
||||
- Write tools are hidden from the tool list
|
||||
|
||||
**Token with `mcp:notes:write` only:**
|
||||
- `list_tools()` returns 54 write-only tools
|
||||
- Read tools are hidden from the tool list
|
||||
|
||||
**Token with both scopes:**
|
||||
- `list_tools()` returns all 90 tools
|
||||
|
||||
**Token with no custom scopes:**
|
||||
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
|
||||
**BasicAuth mode:**
|
||||
- `list_tools()` returns all 90 tools (no filtering)
|
||||
|
||||
**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering.
|
||||
|
||||
### Scope Challenges
|
||||
|
||||
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
|
||||
|
||||
```http
|
||||
HTTP/1.1 403 Forbidden
|
||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||
scope="mcp:notes:write",
|
||||
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
|
||||
|
||||
### Protected Resource Metadata (PRM)
|
||||
|
||||
The server implements RFC 9728's Protected Resource Metadata endpoint:
|
||||
|
||||
**Endpoint:** `GET /.well-known/oauth-protected-resource/mcp`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8001/mcp",
|
||||
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
|
||||
"authorization_servers": ["http://localhost:8080"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
}
|
||||
```
|
||||
|
||||
This allows OAuth clients to discover supported scopes before requesting authorization.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Docker Services
|
||||
|
||||
The development environment includes two MCP server variants:
|
||||
|
||||
| Service | Port | Auth Type | Token Type | Use Case |
|
||||
|---------|------|-----------|------------|----------|
|
||||
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
||||
| `mcp-oauth` | 8001 | OAuth | JWT (configurable) | OAuth testing with JWT tokens |
|
||||
|
||||
### OAuth Service Configuration
|
||||
|
||||
The `mcp-oauth` service uses **Dynamic Client Registration (DCR)** by default and is configured to request JWT tokens:
|
||||
|
||||
**Default Configuration (DCR with JWT tokens):**
|
||||
```yaml
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
ports:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
||||
```
|
||||
|
||||
**With Pre-Configured Credentials:**
|
||||
```yaml
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
ports:
|
||||
- 127.0.0.1:8001:8001
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- **No credentials needed** - DCR automatically registers the client on first start
|
||||
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
||||
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
|
||||
- **Token verifier supports both** - Can handle JWT and opaque tokens
|
||||
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
|
||||
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
|
||||
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
|
||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||
|
||||
### Dynamic Client Registration (DCR)
|
||||
|
||||
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
|
||||
|
||||
**How It Works:**
|
||||
|
||||
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
|
||||
|
||||
```
|
||||
1. Environment Variables (Highest Priority)
|
||||
├─ NEXTCLOUD_OIDC_CLIENT_ID
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||
|
||||
2. Storage File (Second Priority)
|
||||
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
|
||||
|
||||
3. Dynamic Client Registration (Automatic Fallback)
|
||||
├─ Discovers registration endpoint from /.well-known/openid-configuration
|
||||
├─ Registers new client with requested scopes and token type
|
||||
├─ Saves credentials to storage file for future use
|
||||
└─ Client credentials persist across restarts
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
DCR automatically configures the client based on environment variables:
|
||||
|
||||
```bash
|
||||
# Minimal DCR configuration (no credentials needed!)
|
||||
export NEXTCLOUD_HOST=http://localhost:8080
|
||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||
```
|
||||
|
||||
**Credential Storage:**
|
||||
|
||||
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
|
||||
- File has restrictive permissions (0600 - owner read/write only)
|
||||
- Credentials are reused on subsequent starts (no re-registration needed)
|
||||
- Storage file is checked for expiration (auto-regenerates if expired)
|
||||
|
||||
**Format:**
|
||||
```json
|
||||
{
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"client_id_issued_at": 1761097039,
|
||||
"client_secret_expires_at": 2076457039,
|
||||
"redirect_uris": ["http://localhost:8000/oauth/callback"]
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- ✅ Zero-configuration OAuth setup
|
||||
- ✅ Automatic credential management
|
||||
- ✅ Supports both JWT and opaque tokens
|
||||
- ✅ Credentials persist across container restarts
|
||||
- ✅ Automatic re-registration if credentials expire
|
||||
- ✅ Properly sets `allowed_scopes` for JWT token validation
|
||||
|
||||
### Manual Client Creation
|
||||
|
||||
Manual client creation is **optional** but may be preferred when:
|
||||
- You want explicit control over client configuration
|
||||
- You're deploying to production environments with strict security policies
|
||||
- You need to pre-provision OAuth clients before deployment
|
||||
|
||||
**Create Client via OCC Command:**
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
"Nextcloud MCP Server" \
|
||||
"http://localhost:8000/oauth/callback"
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```json
|
||||
{
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"token_type": "jwt",
|
||||
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
|
||||
}
|
||||
```
|
||||
|
||||
**Configure MCP Server with Pre-Configured Credentials:**
|
||||
|
||||
```bash
|
||||
# Option 1: Environment variables (highest priority)
|
||||
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
|
||||
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
|
||||
|
||||
# Option 2: Storage file (second priority)
|
||||
# Save the JSON response to .nextcloud_oauth_client.json
|
||||
# Server will automatically load it on startup
|
||||
```
|
||||
|
||||
When credentials are provided via environment variables or storage file, **DCR is skipped**.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
```
|
||||
┌──────────────────┐ OAuth Flow ┌──────────────────┐
|
||||
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
|
||||
│ (Claude, etc) │ │ Server │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
│ JWT Access Token │
|
||||
│ { │
|
||||
│ "scope": "openid mcp:notes:read mcp:notes:write" │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ │
|
||||
v │
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Nextcloud MCP Server │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ NextcloudTokenVerifier │ │
|
||||
│ │ - JWT signature verification (JWKS) │ │
|
||||
│ │ - Introspection endpoint (opaque tokens) │ │
|
||||
│ │ - Userinfo fallback (last resort) │ │
|
||||
│ └───────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ Dynamic Tool Filtering (list_tools) │ │
|
||||
│ │ - Get user scopes from verified token │ │
|
||||
│ │ - Filter tools based on @require_scopes metadata │ │
|
||||
│ │ - Return only accessible tools │ │
|
||||
│ └───────────────────┬───────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ v │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ Tool Execution (@require_scopes decorator) │ │
|
||||
│ │ - Check token scopes before execution │ │
|
||||
│ │ - Raise InsufficientScopeError if missing │ │
|
||||
│ │ - Return 403 with WWW-Authenticate header │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
|
||||
- **Three-tier validation strategy:**
|
||||
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
|
||||
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
|
||||
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
|
||||
- Scope extraction from token payload (JWT) or introspection response (opaque)
|
||||
- Token caching with TTL to reduce repeated validations
|
||||
- Supports both access token formats transparently
|
||||
|
||||
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
|
||||
- `@require_scopes()` decorator for tools
|
||||
- `get_required_scopes()` - Extract scope requirements from functions
|
||||
- `has_required_scopes()` - Check if user has necessary scopes
|
||||
- `InsufficientScopeError` exception for WWW-Authenticate challenges
|
||||
|
||||
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`)
|
||||
- Overrides FastMCP's `list_tools()` method
|
||||
- Filters based on user's OAuth token scopes (JWT and Bearer)
|
||||
- Only active in OAuth mode
|
||||
- Bypassed in BasicAuth mode
|
||||
|
||||
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
||||
- RFC 9728 compliant
|
||||
|
||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||
- Catches `InsufficientScopeError`
|
||||
- Returns 403 with `WWW-Authenticate` header
|
||||
- Includes missing scopes and PRM endpoint URL
|
||||
|
||||
### Token Validation Flow
|
||||
|
||||
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ verify_token(token) │
|
||||
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
├──> 1. Check cache (lines 106-109)
|
||||
│ ├─ Hit: Return cached AccessToken
|
||||
│ └─ Miss: Continue to validation
|
||||
│
|
||||
├──> 2. JWT Format Check (lines 112-124)
|
||||
│ ├─ Token has 3 parts (header.payload.signature)?
|
||||
│ │ └─ Yes: Attempt JWT verification
|
||||
│ │ ├─ Verify signature with JWKS (RS256)
|
||||
│ │ ├─ Validate issuer, expiration
|
||||
│ │ ├─ Extract scopes from payload
|
||||
│ │ └─ Success: Return AccessToken
|
||||
│ └─ Fail/Not JWT: Continue to introspection
|
||||
│
|
||||
├──> 3. Introspection (lines 126-134)
|
||||
│ ├─ POST to /apps/oidc/introspect
|
||||
│ ├─ Authenticate with client credentials
|
||||
│ ├─ Response contains:
|
||||
│ │ • active: true/false
|
||||
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
||||
│ │ • sub, exp, iat, client_id
|
||||
│ ├─ Extract scopes from response
|
||||
│ └─ Success: Return AccessToken
|
||||
│
|
||||
└──> 4. Userinfo Fallback (lines 137-142)
|
||||
├─ GET /apps/oidc/userinfo
|
||||
├─ Bearer token in Authorization header
|
||||
├─ Infer scopes from response claims
|
||||
└─ Return AccessToken or None
|
||||
```
|
||||
|
||||
**Validation Priorities:**
|
||||
|
||||
| Token Type | Method | Performance | Scope Access | Code Reference |
|
||||
|------------|--------|-------------|--------------|----------------|
|
||||
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
|
||||
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
|
||||
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
|
||||
|
||||
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
|
||||
```python
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
jwks_uri=jwks_uri, # Enables JWT verification
|
||||
issuer=jwt_validation_issuer, # For JWT issuer validation
|
||||
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
|
||||
client_id=client_id, # Required for introspection auth
|
||||
client_secret=client_secret, # Required for introspection auth
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Infrastructure
|
||||
|
||||
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
|
||||
|
||||
**Test Files:**
|
||||
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
|
||||
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
|
||||
- `tests/conftest.py` - Shared fixtures for JWT testing
|
||||
|
||||
### Consent Scenario Tests
|
||||
|
||||
Four test scenarios verify scope-based tool filtering with different consent levels:
|
||||
|
||||
#### 1. No Custom Scopes (0 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||
|
||||
#### 2. Read-Only Access (36 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `mcp:notes:read` only
|
||||
**Expected:** 36 read-only tools visible, write tools hidden
|
||||
**Verifies:** Read tools accessible, write tools filtered out
|
||||
|
||||
#### 3. Write-Only Access (54 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `mcp:notes:write` only
|
||||
**Expected:** 54 write tools visible, read tools hidden
|
||||
**Verifies:** Write tools accessible, read tools filtered out
|
||||
|
||||
#### 4. Full Access (90 tools)
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
|
||||
**Expected:** All 90 tools visible
|
||||
**Verifies:** Full access when user grants all custom scopes
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
**OAuth Client Fixtures:**
|
||||
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||
|
||||
**Token Fixtures:**
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
|
||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||
|
||||
**MCP Client Fixtures:**
|
||||
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
|
||||
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
|
||||
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
|
||||
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
|
||||
|
||||
### Running Tests
|
||||
|
||||
**All consent scenario tests:**
|
||||
```bash
|
||||
uv run pytest tests/server/test_scope_authorization.py -v
|
||||
```
|
||||
|
||||
**JWT OAuth integration tests:**
|
||||
```bash
|
||||
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
|
||||
```
|
||||
|
||||
**With visible browser (debugging):**
|
||||
```bash
|
||||
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
|
||||
```
|
||||
|
||||
### Test Configuration
|
||||
|
||||
**Playwright Browser:**
|
||||
- Default: Chromium
|
||||
- Recommended for CI: Firefox (`--browser firefox`)
|
||||
- Debugging: Add `--headed` flag
|
||||
|
||||
**OAuth Flow:**
|
||||
- Uses automated Playwright browser automation
|
||||
- Completes OAuth consent flow programmatically
|
||||
- Creates separate OAuth client for each scenario
|
||||
- Each user gets unique access token
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: JWT Issuer Validation Failed
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING JWT verification failed, will try other methods
|
||||
✅ Extracted scopes from access token: {'openid', 'profile'}
|
||||
```
|
||||
|
||||
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
|
||||
- Using `localhost` vs `127.0.0.1` inconsistently
|
||||
- MCP server uses internal URL but clients use public URL
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Option 1: Use consistent URLs
|
||||
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
# Ensure all test fixtures also use localhost:8080
|
||||
|
||||
# Option 2: Check discovery document
|
||||
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
|
||||
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||
```
|
||||
|
||||
**Impact if not fixed:**
|
||||
- JWT validation falls back to userinfo endpoint
|
||||
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
|
||||
- Result: 0 tools visible or incorrect tool filtering
|
||||
|
||||
### Issue: Scopes Not Present in JWT
|
||||
|
||||
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
|
||||
|
||||
**Cause:** Client's `allowed_scopes` is empty or not configured
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check client configuration
|
||||
docker compose exec app php occ oidc:list
|
||||
|
||||
# Look for allowed_scopes in output
|
||||
# If empty, recreate client with --allowed_scopes
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
"Client Name" \
|
||||
"http://callback/url"
|
||||
```
|
||||
|
||||
### Issue: All Tools Visible Despite Read-Only Token
|
||||
|
||||
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
|
||||
|
||||
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Verify OAuth mode is active
|
||||
docker compose logs mcp-oauth | grep "OAuth mode"
|
||||
|
||||
# Should see: "Running in OAuth mode"
|
||||
|
||||
# If not, check environment variables:
|
||||
docker compose exec mcp-oauth env | grep NEXTCLOUD_OIDC
|
||||
|
||||
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
||||
```
|
||||
|
||||
### Verifying DCR Scope Configuration
|
||||
|
||||
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
|
||||
|
||||
**To verify DCR scopes are working:**
|
||||
|
||||
```bash
|
||||
# Check the registered client's allowed_scopes via database
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||
|
||||
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
|
||||
```
|
||||
|
||||
**If scopes are missing:**
|
||||
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
|
||||
2. Check MCP server startup logs for the scopes being requested
|
||||
3. Verify DCR is enabled in Nextcloud OIDC app settings
|
||||
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
|
||||
|
||||
### Issue: Token Type Case Sensitivity
|
||||
|
||||
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
|
||||
|
||||
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
|
||||
|
||||
**Solution:** Always use lowercase:
|
||||
```bash
|
||||
# Correct
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
||||
|
||||
# Incorrect (will generate opaque tokens)
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
|
||||
```
|
||||
|
||||
### Issue: Missing WWW-Authenticate Header
|
||||
|
||||
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
|
||||
|
||||
**Cause:** Server not in OAuth mode, or exception not being caught
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check server logs for OAuth mode
|
||||
docker compose logs mcp-oauth | grep "WWW-Authenticate scope challenges enabled"
|
||||
|
||||
# Should see this during startup
|
||||
|
||||
# Check exception handling
|
||||
docker compose logs mcp-oauth | grep "InsufficientScopeError"
|
||||
```
|
||||
|
||||
### Debugging Tools
|
||||
|
||||
**Check JWT contents:**
|
||||
```bash
|
||||
# Decode JWT (base64 decode the payload)
|
||||
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
|
||||
```
|
||||
|
||||
**Check database scopes:**
|
||||
```bash
|
||||
# View access tokens with scopes
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
|
||||
|
||||
# View user consents
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
|
||||
```
|
||||
|
||||
**Check server logs:**
|
||||
```bash
|
||||
# Follow JWT verification logs
|
||||
docker compose logs -f mcp-oauth | grep -E "JWT|scope|tool"
|
||||
|
||||
# Check for issuer mismatches
|
||||
docker compose logs mcp-oauth | grep -i issuer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Deployment Checklist
|
||||
|
||||
✅ **Use JWT Tokens** - Enable `token_type=jwt` for better performance
|
||||
✅ **Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
|
||||
✅ **Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
|
||||
✅ **Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
|
||||
✅ **Secure Credentials** - Store client credentials securely (environment variables or secrets management)
|
||||
✅ **Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
|
||||
✅ **Enable Logging** - Configure appropriate log levels for JWT verification
|
||||
|
||||
### Production Configuration Example
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (production)
|
||||
mcp-oauth:
|
||||
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
ports:
|
||||
- "8001:8001"
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Token Storage:**
|
||||
- Never commit credentials to version control
|
||||
- Use environment variables or secrets management
|
||||
- Rotate client secrets periodically
|
||||
|
||||
**Scope Configuration:**
|
||||
- Grant minimum necessary scopes to clients
|
||||
- Use read-only tokens for AI assistants that don't need write access
|
||||
- Review OAuth client list regularly
|
||||
|
||||
**Network Security:**
|
||||
- Use HTTPS in production
|
||||
- Ensure issuer URL matches public URL
|
||||
- Configure proper CORS headers
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Key Metrics:**
|
||||
- JWT verification success/failure rate
|
||||
- Scope challenge frequency (indicates clients with insufficient scopes)
|
||||
- Token validation latency
|
||||
- Tool execution by scope (identify unused scopes)
|
||||
|
||||
**Log Patterns:**
|
||||
```bash
|
||||
# Success
|
||||
INFO JWT verified successfully for user: admin
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
||||
|
||||
# Failures
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING Missing required scopes: mcp:notes:write
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
|
||||
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Potential Improvements:**
|
||||
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
|
||||
- Resource-level filtering (apply to MCP resources, not just tools)
|
||||
- Automatic scope discovery from decorated tools
|
||||
- Admin UI for scope management
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Standards
|
||||
|
||||
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
|
||||
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
|
||||
- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
|
||||
|
||||
### Related Documentation
|
||||
|
||||
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
|
||||
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
|
||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
|
||||
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
|
||||
|
||||
### External Resources
|
||||
|
||||
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
|
||||
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
|
||||
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
|
||||
|
||||
---
|
||||
|
||||
**Implementation Date:** 2025-10-21 to 2025-10-23
|
||||
**Version:** 1.0.0
|
||||
**Status:** ✅ Production Ready
|
||||
@@ -14,6 +14,7 @@ Start here to identify your issue:
|
||||
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
||||
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
||||
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
||||
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
|
||||
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
||||
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
||||
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
|
||||
@@ -407,6 +408,94 @@ http://localhost:8000/oauth/callback
|
||||
|
||||
---
|
||||
|
||||
### Limited Scopes - Only Seeing Notes Tools
|
||||
|
||||
**Symptoms**:
|
||||
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||
- Only Notes tools are available (7 tools instead of 90+)
|
||||
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
|
||||
|
||||
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
|
||||
|
||||
**Diagnosis**:
|
||||
|
||||
Check what scopes the client has been granted:
|
||||
|
||||
```bash
|
||||
# View registered clients and their allowed scopes
|
||||
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, allowed_scopes}'
|
||||
```
|
||||
|
||||
Look for the client's `allowed_scopes` field. If it's empty or only contains notes scopes, that's the issue.
|
||||
|
||||
**Solution**:
|
||||
|
||||
**Option 1: Delete Client and Reconnect** (Recommended for MCP clients)
|
||||
|
||||
```bash
|
||||
# Find the client ID
|
||||
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, client_id}'
|
||||
|
||||
# Delete the client
|
||||
php occ oidc:delete <client_id>
|
||||
|
||||
# Reconnect from Claude Code
|
||||
# This will trigger a new OAuth flow where you can grant all scopes
|
||||
```
|
||||
|
||||
When reconnecting, you'll see a consent screen listing all available scopes. Make sure to approve all the scopes you want the client to access.
|
||||
|
||||
**Option 2: Update Client Scopes via CLI**
|
||||
|
||||
```bash
|
||||
# Update allowed scopes for an existing client
|
||||
php occ oidc:update <client_id> \
|
||||
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
|
||||
|
||||
# User will need to reconnect to get new token with updated scopes
|
||||
```
|
||||
|
||||
**Verify Available Scopes**:
|
||||
|
||||
Check what scopes the MCP server advertises:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_supported'
|
||||
|
||||
# Should show all 16 scope categories:
|
||||
# - openid
|
||||
# - mcp:notes:read, mcp:notes:write
|
||||
# - mcp:calendar:read, mcp:calendar:write
|
||||
# - mcp:contacts:read, mcp:contacts:write
|
||||
# - mcp:cookbook:read, mcp:cookbook:write
|
||||
# - mcp:deck:read, mcp:deck:write
|
||||
# - mcp:tables:read, mcp:tables:write
|
||||
# - mcp:files:read, mcp:files:write
|
||||
# - mcp:sharing:read, mcp:sharing:write
|
||||
```
|
||||
|
||||
**Understanding Scope Filtering**:
|
||||
|
||||
The MCP server dynamically filters tools based on the scopes in your access token:
|
||||
- Check server logs for: `✂️ JWT scope filtering: X/90 tools available for scopes: {...}`
|
||||
- This shows how many tools are visible vs total available
|
||||
- Each tool requires specific scopes (read and/or write)
|
||||
|
||||
**Available Scope Categories**:
|
||||
|
||||
| Scope Prefix | Nextcloud App | Read Operations | Write Operations |
|
||||
|--------------|---------------|-----------------|------------------|
|
||||
| `mcp:notes:*` | Notes | Get, search, list | Create, update, delete, append |
|
||||
| `mcp:calendar:*` | Calendar (CalDAV) | Get events, todos, calendars | Create/update/delete events, todos |
|
||||
| `mcp:contacts:*` | Contacts (CardDAV) | Get contacts, address books | Create/update/delete contacts |
|
||||
| `mcp:cookbook:*` | Cookbook | Get recipes, search | Create/update recipes |
|
||||
| `mcp:deck:*` | Deck | Get boards, cards | Create/update boards, cards |
|
||||
| `mcp:tables:*` | Tables | Get rows, tables | Create/update/delete rows |
|
||||
| `mcp:files:*` | Files (WebDAV) | List, read files | Upload, delete, move files |
|
||||
| `mcp:sharing:*` | Sharing | Get shares | Create/update shares |
|
||||
|
||||
---
|
||||
|
||||
## Switching Authentication Modes
|
||||
|
||||
### From BasicAuth to OAuth
|
||||
|
||||
@@ -44,36 +44,52 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
|
||||
---
|
||||
|
||||
### 2. PKCE Support Advertisement in Discovery
|
||||
### 2. PKCE Support (RFC 7636)
|
||||
|
||||
**Status**: 🟢 **PR Submitted** (Pending Review)
|
||||
**Status**: ✅ **Complete** (Merged Upstream)
|
||||
|
||||
**Affected Component**: `oidc` app
|
||||
|
||||
**Issue**: The OIDC discovery endpoint (`/.well-known/openid-configuration`) does not advertise PKCE support in the `code_challenge_methods_supported` field.
|
||||
**Issue**: The OIDC app lacked PKCE (Proof Key for Code Exchange) implementation per RFC 7636.
|
||||
|
||||
**Why It Matters**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Some MCP clients may reject providers without proper PKCE advertisement
|
||||
**Resolution**: Full PKCE support has been implemented and merged upstream into the `oidc` app:
|
||||
|
||||
**Current Behavior**:
|
||||
- PKCE **functionally works** (the OIDC app accepts and validates PKCE)
|
||||
- PKCE just isn't **advertised** in discovery metadata
|
||||
**Authorization Endpoint** (`/authorize`):
|
||||
- Accepts `code_challenge` and `code_challenge_method` parameters
|
||||
- Validates code_challenge format (43-128 characters, unreserved chars only)
|
||||
- Supports both `S256` (SHA-256) and `plain` challenge methods
|
||||
- Stores challenge and method in database for later verification
|
||||
|
||||
**Recommended Fix**: Update `oidc` app to include:
|
||||
**Token Endpoint** (`/token`):
|
||||
- Accepts `code_verifier` parameter
|
||||
- Verifies code_verifier against stored code_challenge using proper algorithm
|
||||
- Uses constant-time comparison to prevent timing attacks
|
||||
- Enforces code_verifier requirement when PKCE was used in authorization
|
||||
|
||||
**Discovery Document**:
|
||||
```json
|
||||
{
|
||||
"code_challenge_methods_supported": ["S256"]
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
**Workaround**: The MCP server implements PKCE validation and logs a warning if not advertised. Functionality still works.
|
||||
**Database**:
|
||||
- New columns: `code_challenge` and `code_challenge_method` in `oc_oauth2_access_tokens`
|
||||
- Migration included for existing installations
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - Submitted 2025-10-13
|
||||
- **Changes**: Adds `code_challenge_methods_supported: ["S256"]` to discovery document when PKCE is enabled
|
||||
- **Size**: +5 lines added, 0 deleted
|
||||
- **Status**: Open, awaiting review
|
||||
**Why It Mattered**:
|
||||
- MCP specification requires PKCE with S256 code challenge method
|
||||
- RFC 7636 PKCE provides security for public clients (no client secret)
|
||||
- RFC 8414 states that absence of `code_challenge_methods_supported` means PKCE is **not supported**
|
||||
- Prevents authorization code interception attacks
|
||||
|
||||
**Upstream PR**: [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - ✅ **Merged 2025-10-20**
|
||||
- **Changes**: Complete PKCE implementation (+194 lines)
|
||||
- Authorization flow with code_challenge validation
|
||||
- Token exchange with code_verifier verification
|
||||
- Database schema updates
|
||||
- Discovery document updates
|
||||
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
|
||||
|
||||
---
|
||||
|
||||
@@ -82,17 +98,17 @@ This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
|
||||
| PR/Issue | Component | Status | Priority | Notes |
|
||||
|----------|-----------|--------|----------|-------|
|
||||
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | 🟢 PR Open | Medium | PKCE advertisement for standards compliance |
|
||||
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
|
||||
|
||||
## What Works Without Patches
|
||||
|
||||
The following functionality works **out of the box** without any patches:
|
||||
|
||||
✅ **OAuth Flow**:
|
||||
- OIDC discovery
|
||||
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
|
||||
- Dynamic client registration
|
||||
- Authorization code flow with PKCE
|
||||
- Token exchange
|
||||
- Authorization code flow with PKCE (S256 and plain methods)
|
||||
- Token exchange with code_verifier verification
|
||||
- Userinfo endpoint
|
||||
|
||||
✅ **MCP Server as Resource Server**:
|
||||
@@ -116,9 +132,9 @@ The following functionality requires upstream patches:
|
||||
- Tables API
|
||||
- Custom app APIs
|
||||
|
||||
🟡 **Standards Compliance** (PKCE advertisement):
|
||||
- Full RFC 8414 compliance
|
||||
- MCP client compatibility guarantee
|
||||
✅ **Standards Compliance**: Now complete with `oidc` app v1.10.0+
|
||||
- ✅ Full RFC 8414 compliance (PKCE advertisement)
|
||||
- ✅ MCP client compatibility guarantee
|
||||
|
||||
## Installation Instructions
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@ 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://127.0.0.1:8000/mcp", client_name="Basic MCP"
|
||||
url="http://localhost:8000/mcp", client_name="Basic MCP"
|
||||
):
|
||||
yield session
|
||||
except RuntimeError as e:
|
||||
@@ -88,7 +88,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
|
||||
async def create_and_hold_session():
|
||||
"""Runs in isolated task - creates session and keeps it alive."""
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
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
|
||||
@@ -141,7 +141,7 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
@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://127.0.0.1:8000/mcp") as (read_stream, write_stream, _):
|
||||
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
|
||||
@@ -150,11 +150,11 @@ async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
@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://127.0.0.1:8000/mcp") as (read1, write1, _):
|
||||
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://127.0.0.1:8001/mcp") as (read2, write2, _):
|
||||
async with streamablehttp_client("http://localhost:8001/mcp") as (read2, write2, _):
|
||||
async with ClientSession(read2, write2) as session2:
|
||||
await session2.initialize()
|
||||
yield session1, session2
|
||||
@@ -195,7 +195,7 @@ async def multi_mcp_clients() -> AsyncGenerator[tuple[ClientSession, ClientSessi
|
||||
# Fixtures work naturally with trio
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client() -> AsyncGenerator[ClientSession, Any]:
|
||||
async with streamablehttp_client("http://127.0.0.1:8000/mcp") as (read, write, _):
|
||||
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
yield session
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# Testing OIDC Consent Feature
|
||||
|
||||
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
|
||||
|
||||
## Setup
|
||||
|
||||
### Volume Mount Configuration
|
||||
|
||||
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
volumes:
|
||||
- ../Software/oidc:/opt/apps/oidc:ro
|
||||
```
|
||||
|
||||
**Why mount outside `/var/www/html/`?**
|
||||
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
|
||||
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
|
||||
- Mounting to `/opt/apps/oidc` avoids rsync entirely
|
||||
- Nextcloud supports multiple app directories via the `apps_paths` configuration
|
||||
|
||||
**How multiple app paths work:**
|
||||
- Nextcloud can load apps from multiple directories
|
||||
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
|
||||
- Apps in default paths (index 0 and 1) are still available
|
||||
- All directories are scanned for apps, but `/opt/apps` is read-only
|
||||
|
||||
This setup allows you to:
|
||||
- Test changes without rebuilding containers
|
||||
- Avoid needing npm/node in the container (JS already built on host)
|
||||
- Iterate quickly on development
|
||||
- Install other Nextcloud apps normally (custom_apps remains writable)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
|
||||
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
|
||||
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
|
||||
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
|
||||
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
|
||||
|
||||
## Starting the Stack
|
||||
|
||||
```bash
|
||||
cd ~/Projects/nextcloud-mcp-server
|
||||
|
||||
# Start fresh (recommended for first test)
|
||||
docker compose down -v
|
||||
docker compose up -d
|
||||
|
||||
# Wait for initialization (check logs)
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
The post-installation hooks will:
|
||||
1. Configure custom_apps path (already done)
|
||||
2. Enable OIDC app from mounted directory
|
||||
3. Run database migrations (including consent table creation)
|
||||
4. Configure OIDC settings
|
||||
|
||||
## Verifying Installation
|
||||
|
||||
### Before Container Restart
|
||||
|
||||
Before running `docker compose up -d`, the consent feature will NOT be active:
|
||||
- ❌ No `oc_oidc_user_consents` table in database
|
||||
- ❌ Migration 0015 not applied yet
|
||||
- ❌ ConsentController class not loaded
|
||||
- ❌ Consent routes not registered
|
||||
|
||||
You can verify this with:
|
||||
```bash
|
||||
# Check migrations applied (should stop at 0014)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
|
||||
|
||||
# Check for consent table (should return empty)
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
|
||||
```
|
||||
|
||||
### After Container Restart
|
||||
|
||||
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
|
||||
- ✅ `oc_oidc_user_consents` table exists
|
||||
- ✅ Migration 0015 (Version0015Date20251123100100) applied
|
||||
- ✅ ConsentController routes registered
|
||||
- ✅ Consent screen appears during OAuth flows
|
||||
|
||||
### Check App Status
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ app:list | grep -A 2 oidc
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
- oidc: 1.10.0 (enabled)
|
||||
```
|
||||
|
||||
### Verify App Paths Configuration
|
||||
|
||||
Verify that `/opt/apps` is registered as an additional app directory:
|
||||
|
||||
```bash
|
||||
# Check configured app paths
|
||||
docker compose exec app php occ config:system:get apps_paths
|
||||
|
||||
# Verify the mount is accessible
|
||||
docker compose exec app ls -la /opt/apps/oidc/
|
||||
|
||||
# Verify custom_apps is writable (for normal app installation)
|
||||
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
|
||||
docker compose exec app rm -f /var/www/html/custom_apps/.test
|
||||
```
|
||||
|
||||
Expected: Output should show multiple app paths including index 2 (/opt/apps).
|
||||
|
||||
### Verify Consent Files
|
||||
|
||||
```bash
|
||||
# Check controller exists in mounted location
|
||||
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
|
||||
|
||||
# Check Vue component exists
|
||||
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
|
||||
|
||||
# Check built JS exists
|
||||
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||
```
|
||||
|
||||
### Verify Database Migration
|
||||
|
||||
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
|
||||
|
||||
```bash
|
||||
# Check if consent table exists
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
|
||||
|
||||
# Check table structure
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
|
||||
|
||||
# Verify migration 0015 was applied
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
|
||||
```
|
||||
|
||||
Expected table structure:
|
||||
- id: int(10) unsigned, auto_increment, primary key
|
||||
- user_id: varchar(256), not null
|
||||
- client_id: int(10) unsigned, not null
|
||||
- scopes_granted: varchar(512), not null
|
||||
- created_at: int(10) unsigned, not null
|
||||
- updated_at: int(10) unsigned, not null
|
||||
- expires_at: int(10) unsigned, nullable
|
||||
|
||||
### Verify Routes
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ router:list | grep consent
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
oidc.Consent.show GET apps/oidc/consent
|
||||
oidc.Consent.grant POST apps/oidc/consent/grant
|
||||
oidc.Consent.deny POST apps/oidc/consent/deny
|
||||
```
|
||||
|
||||
## Testing the Consent Flow
|
||||
|
||||
### 1. Create an OAuth Client
|
||||
|
||||
The JWT client is automatically created by the post-installation hooks:
|
||||
|
||||
```bash
|
||||
# Check if JWT client exists
|
||||
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
|
||||
```
|
||||
|
||||
### 2. Initiate Authorization Flow
|
||||
|
||||
You can test using the MCP OAuth container or manually:
|
||||
|
||||
**Option A: Using MCP OAuth container**
|
||||
```bash
|
||||
# The mcp-oauth container will trigger the OAuth flow
|
||||
docker compose logs -f mcp-oauth
|
||||
```
|
||||
|
||||
**Option B: Manual browser test**
|
||||
1. Get client_id from the JWT client JSON
|
||||
2. Visit in browser:
|
||||
```
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
|
||||
```
|
||||
|
||||
### 3. Expected Behavior
|
||||
|
||||
**First Authorization:**
|
||||
1. User logs in (if not already authenticated)
|
||||
2. **Consent screen appears** with:
|
||||
- Application name: "Nextcloud MCP Server JWT"
|
||||
- List of requested scopes with descriptions:
|
||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||
- ✓ Profile information (profile)
|
||||
- ✓ Email address (email)
|
||||
- ✓ mcp:notes:read (custom scope, shown as-is)
|
||||
- ✓ mcp:notes:write (custom scope, shown as-is)
|
||||
- "Allow" and "Deny" buttons
|
||||
3. User selects scopes and clicks "Allow"
|
||||
4. Authorization proceeds with selected scopes
|
||||
5. Consent is stored in database
|
||||
|
||||
**Subsequent Authorizations:**
|
||||
- Same scopes → No consent screen (uses stored consent)
|
||||
- Different scopes → Consent screen appears again
|
||||
- If user clicks "Deny" → Returns `error=access_denied` to client
|
||||
|
||||
### 4. Verify Consent Stored
|
||||
|
||||
After granting consent:
|
||||
|
||||
```bash
|
||||
# View all stored consents with formatted timestamps
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
|
||||
SELECT
|
||||
user_id,
|
||||
client_id,
|
||||
scopes_granted,
|
||||
FROM_UNIXTIME(created_at) as created,
|
||||
FROM_UNIXTIME(updated_at) as updated,
|
||||
FROM_UNIXTIME(expires_at) as expires
|
||||
FROM oc_oidc_user_consents;
|
||||
" nextcloud
|
||||
|
||||
# Or for a compact view:
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Consent Screen Not Appearing
|
||||
|
||||
**Check browser console** (F12 → Console tab):
|
||||
```
|
||||
# Look for JS errors like:
|
||||
Failed to load resource: js/oidc-consent.js
|
||||
```
|
||||
|
||||
**Check Nextcloud logs:**
|
||||
```bash
|
||||
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
|
||||
```
|
||||
|
||||
**Verify JS file loaded:**
|
||||
```bash
|
||||
# Check file exists and has correct size (~73KB)
|
||||
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
|
||||
```
|
||||
|
||||
**Clear Nextcloud caches:**
|
||||
```bash
|
||||
docker compose exec app php occ maintenance:repair
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### Migration Didn't Run
|
||||
|
||||
**Check which migrations have been applied:**
|
||||
```bash
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
|
||||
```
|
||||
|
||||
Expected to see `Version0015Date20251123100100` in the list.
|
||||
|
||||
**Manually trigger migrations:**
|
||||
```bash
|
||||
# Disable and re-enable app (triggers all pending migrations)
|
||||
docker compose exec app php occ app:disable oidc
|
||||
docker compose exec app php occ app:enable oidc
|
||||
|
||||
# Verify migration 0015 was applied
|
||||
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
|
||||
```
|
||||
|
||||
### Routes Not Registered
|
||||
|
||||
If `router:list` doesn't show consent routes:
|
||||
|
||||
```bash
|
||||
# The autoloader might not have picked up new classes
|
||||
# Restart the container
|
||||
docker compose restart app
|
||||
|
||||
# Wait for it to be ready
|
||||
sleep 10
|
||||
|
||||
# Try again
|
||||
docker compose exec app php occ router:list | grep consent
|
||||
```
|
||||
|
||||
If still not working, check if ConsentController is accessible:
|
||||
```bash
|
||||
docker compose exec app php -r "
|
||||
require_once '/var/www/html/lib/base.php';
|
||||
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
|
||||
if (class_exists(\$class)) {
|
||||
echo \"Class exists\n\";
|
||||
} else {
|
||||
echo \"Class not found\n\";
|
||||
}
|
||||
"
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Frontend Changes (Vue.js)
|
||||
|
||||
1. Edit source file on host:
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
# Edit src/Consent.vue
|
||||
```
|
||||
|
||||
2. Rebuild JS:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
|
||||
|
||||
### Backend Changes (PHP)
|
||||
|
||||
1. Edit files on host:
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
# Edit lib/Controller/ConsentController.php or other PHP files
|
||||
```
|
||||
|
||||
2. Changes are immediately visible (PHP is interpreted, no build step)
|
||||
|
||||
3. For new classes or major changes, restart container:
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
### Database Schema Changes
|
||||
|
||||
If you modify the migration:
|
||||
|
||||
```bash
|
||||
# Changes won't be picked up if migration already ran
|
||||
# Need to recreate the database:
|
||||
docker compose down -v # Removes volumes
|
||||
docker compose up -d # Fresh start with clean DB
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
### Reset Everything
|
||||
|
||||
```bash
|
||||
cd ~/Projects/nextcloud-mcp-server
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
This removes:
|
||||
- All containers
|
||||
- Database volume (all data)
|
||||
- OAuth client credentials
|
||||
|
||||
### Keep Data, Restart App
|
||||
|
||||
```bash
|
||||
docker compose restart app
|
||||
```
|
||||
|
||||
This preserves:
|
||||
- Database (consents, clients, users)
|
||||
- OAuth client credentials
|
||||
|
||||
## Development Workflow Summary
|
||||
|
||||
1. **Make changes** in `~/Software/oidc`
|
||||
2. **Build JS** if you changed Vue files: `npm run build`
|
||||
3. **Test immediately** - refresh browser or restart container
|
||||
4. **No need** to rebuild Docker images or reinstall app
|
||||
5. **Iterate quickly** with instant feedback
|
||||
|
||||
## Production Deployment
|
||||
|
||||
When ready to deploy:
|
||||
|
||||
1. **Create patch file** (already done):
|
||||
```bash
|
||||
cd ~/Software/oidc
|
||||
git format-patch master --stdout > user-consent-feature.patch
|
||||
```
|
||||
|
||||
2. **Test patch** in clean environment:
|
||||
```bash
|
||||
# In a production-like environment
|
||||
cd /path/to/production/oidc
|
||||
git apply user-consent-feature.patch
|
||||
npm install
|
||||
npm run build
|
||||
php occ app:disable oidc
|
||||
php occ app:enable oidc
|
||||
```
|
||||
|
||||
3. **Verify migration** runs automatically on app enable
|
||||
|
||||
4. **Submit pull request** to upstream repository
|
||||
+394
-79
@@ -11,9 +11,17 @@ from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
NextcloudTokenVerifier,
|
||||
get_access_token_scopes,
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
@@ -135,6 +143,106 @@ 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
|
||||
# Default: all app-specific read/write scopes
|
||||
default_scopes = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write "
|
||||
"calendar:read calendar:write "
|
||||
"todo:read todo:write "
|
||||
"contacts:read contacts:write "
|
||||
"cookbook:read cookbook:write "
|
||||
"deck:read deck:write "
|
||||
"tables:read tables:write "
|
||||
"files:read files:write "
|
||||
"sharing:read sharing:write"
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
|
||||
# Special case: "bearer" should remain capitalized for compatibility
|
||||
if token_type != "jwt":
|
||||
token_type = "Bearer"
|
||||
logger.info(f"Requesting token type: {token_type}")
|
||||
|
||||
# Load or register client
|
||||
from nextcloud_mcp_server.auth.client_registration import (
|
||||
load_or_register_client,
|
||||
)
|
||||
|
||||
client_info = await load_or_register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
storage_path=storage_path,
|
||||
client_name=f"Nextcloud MCP Server ({token_type})",
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||
return (client_info.client_id, client_info.client_secret)
|
||||
|
||||
# No credentials available
|
||||
raise ValueError(
|
||||
"OAuth mode requires either:\n"
|
||||
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
|
||||
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
|
||||
"3. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""
|
||||
@@ -187,45 +295,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")
|
||||
@@ -278,59 +365,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")
|
||||
@@ -347,9 +435,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
# Asynchronously get the OAuth configuration
|
||||
import asyncio
|
||||
import anyio
|
||||
|
||||
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
|
||||
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
@@ -393,6 +481,55 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
original_list_tools = mcp._tool_manager.list_tools
|
||||
|
||||
def list_tools_filtered():
|
||||
"""List tools filtered by user's token scopes (JWT and Bearer tokens)."""
|
||||
# Get user's scopes from token using MCP SDK's contextvar
|
||||
# This works for all request types including list_tools
|
||||
user_scopes = get_access_token_scopes()
|
||||
is_jwt = is_jwt_token()
|
||||
logger.info(
|
||||
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
|
||||
f"User scopes: {user_scopes}"
|
||||
)
|
||||
|
||||
# Get all tools
|
||||
all_tools = original_list_tools()
|
||||
|
||||
# Filter tools based on user's token scopes (both JWT and opaque tokens)
|
||||
# JWT tokens have scopes embedded in payload
|
||||
# Opaque tokens get scopes via introspection endpoint
|
||||
# Claude Code now properly respects PRM endpoint for scope discovery
|
||||
if user_scopes:
|
||||
allowed_tools = [
|
||||
tool
|
||||
for tool in all_tools
|
||||
if has_required_scopes(tool.fn, user_scopes)
|
||||
]
|
||||
token_type = "JWT" if is_jwt else "Bearer"
|
||||
logger.info(
|
||||
f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
|
||||
f"available for scopes: {user_scopes}"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode or no token - show all tools
|
||||
allowed_tools = all_tools
|
||||
logger.info(
|
||||
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
|
||||
)
|
||||
|
||||
# Return the Tool objects directly (they're already in the correct format)
|
||||
return allowed_tools
|
||||
|
||||
# Replace the tool manager's list_tools method
|
||||
mcp._tool_manager.list_tools = list_tools_filtered
|
||||
logger.info(
|
||||
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
lifespan = None
|
||||
@@ -405,7 +542,116 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
|
||||
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
|
||||
routes = []
|
||||
if oauth_enabled:
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""RFC 9728 Protected Resource Metadata endpoint."""
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
# Append /mcp to match the actual resource path (FastMCP streamable-http endpoint)
|
||||
resource_url = f"{mcp_server_url}/mcp"
|
||||
|
||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer_url:
|
||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource": resource_url,
|
||||
"scopes_supported": [
|
||||
"openid",
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"todo:read",
|
||||
"todo:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
],
|
||||
"authorization_servers": [public_issuer_url],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
)
|
||||
|
||||
# Register PRM endpoint at both path-based and root locations per RFC 9728
|
||||
# Path-based discovery: /.well-known/oauth-protected-resource{path}
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource/mcp",
|
||||
oauth_protected_resource_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
# Root discovery (fallback): /.well-known/oauth-protected-resource
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
oauth_protected_resource_metadata,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
|
||||
)
|
||||
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
app = Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # Allow all origins for development
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["*"],
|
||||
)
|
||||
|
||||
# Add exception handler for scope challenges (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
|
||||
@app.exception_handler(InsufficientScopeError)
|
||||
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
|
||||
"""Return 403 with WWW-Authenticate header for scope challenges."""
|
||||
resource_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
scope_str = " ".join(exc.missing_scopes)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer error="insufficient_scope", '
|
||||
f'scope="{scope_str}", '
|
||||
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource/mcp"'
|
||||
)
|
||||
},
|
||||
content={
|
||||
"error": "insufficient_scope",
|
||||
"scopes_required": exc.missing_scopes,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
return app
|
||||
|
||||
@@ -471,6 +717,41 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
show_default=True,
|
||||
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-host",
|
||||
envvar="NEXTCLOUD_HOST",
|
||||
help="Nextcloud instance URL (can also use NEXTCLOUD_HOST env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-username",
|
||||
envvar="NEXTCLOUD_USERNAME",
|
||||
help="Nextcloud username for BasicAuth (can also use NEXTCLOUD_USERNAME env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--nextcloud-password",
|
||||
envvar="NEXTCLOUD_PASSWORD",
|
||||
help="Nextcloud password for BasicAuth (can also use NEXTCLOUD_PASSWORD env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-scopes",
|
||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||
default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
||||
show_default=True,
|
||||
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-token-type",
|
||||
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
|
||||
default="bearer",
|
||||
show_default=True,
|
||||
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
|
||||
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--public-issuer-url",
|
||||
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
|
||||
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
|
||||
)
|
||||
def run(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -482,6 +763,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.
|
||||
@@ -493,24 +780,52 @@ def run(
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# BasicAuth mode (legacy)
|
||||
# BasicAuth mode with CLI options
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
|
||||
--nextcloud-username=admin --nextcloud-password=secret
|
||||
|
||||
# BasicAuth mode with env vars (recommended for credentials)
|
||||
$ export NEXTCLOUD_HOST=https://cloud.example.com
|
||||
$ export NEXTCLOUD_USERNAME=admin
|
||||
$ export NEXTCLOUD_PASSWORD=secret
|
||||
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
|
||||
# OAuth mode with auto-registration
|
||||
$ nextcloud-mcp-server --oauth
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
|
||||
|
||||
# OAuth mode with pre-configured client
|
||||
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-client-id=xxx --oauth-client-secret=yyy
|
||||
|
||||
# OAuth mode with custom scopes and JWT tokens
|
||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
||||
|
||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||
--public-issuer-url=http://localhost:8080
|
||||
"""
|
||||
# Set OAuth env vars from CLI options if provided
|
||||
# Set env vars from CLI options if provided
|
||||
if nextcloud_host:
|
||||
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
|
||||
if nextcloud_username:
|
||||
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
|
||||
if nextcloud_password:
|
||||
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
|
||||
if oauth_client_id:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||
if oauth_client_secret:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||
if oauth_storage_path:
|
||||
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
|
||||
if oauth_scopes:
|
||||
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
|
||||
if oauth_token_type:
|
||||
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
|
||||
if mcp_server_url:
|
||||
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
||||
if public_issuer_url:
|
||||
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
|
||||
|
||||
# Force OAuth mode if explicitly requested
|
||||
if oauth is True:
|
||||
|
||||
@@ -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
|
||||
@@ -7,13 +8,14 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClientInfo:
|
||||
"""Client registration information."""
|
||||
"""Client registration information with RFC 7592 support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -22,12 +24,16 @@ class ClientInfo:
|
||||
client_id_issued_at: int,
|
||||
client_secret_expires_at: int,
|
||||
redirect_uris: list[str],
|
||||
registration_access_token: str | None = None,
|
||||
registration_client_uri: str | None = None,
|
||||
):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.client_id_issued_at = client_id_issued_at
|
||||
self.client_secret_expires_at = client_secret_expires_at
|
||||
self.redirect_uris = redirect_uris
|
||||
self.registration_access_token = registration_access_token
|
||||
self.registration_client_uri = registration_client_uri
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
@@ -41,13 +47,18 @@ class ClientInfo:
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
result = {
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"client_id_issued_at": self.client_id_issued_at,
|
||||
"client_secret_expires_at": self.client_secret_expires_at,
|
||||
"redirect_uris": self.redirect_uris,
|
||||
}
|
||||
if self.registration_access_token:
|
||||
result["registration_access_token"] = self.registration_access_token
|
||||
if self.registration_client_uri:
|
||||
result["registration_client_uri"] = self.registration_client_uri
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
||||
@@ -58,6 +69,8 @@ class ClientInfo:
|
||||
client_id_issued_at=data["client_id_issued_at"],
|
||||
client_secret_expires_at=data["client_secret_expires_at"],
|
||||
redirect_uris=data["redirect_uris"],
|
||||
registration_access_token=data.get("registration_access_token"),
|
||||
registration_client_uri=data.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +80,7 @@ async def register_client(
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
@@ -77,6 +91,7 @@ async def register_client(
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with registration details
|
||||
@@ -95,6 +110,7 @@ async def register_client(
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": scopes,
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
@@ -113,11 +129,24 @@ async def register_client(
|
||||
logger.info(
|
||||
f"Successfully registered client: {client_info.get('client_id')}"
|
||||
)
|
||||
expires_at = dt.datetime.fromtimestamp(
|
||||
client_info.get("client_secret_expires_at")
|
||||
)
|
||||
logger.info(
|
||||
f"Client expires at: {client_info.get('client_secret_expires_at')} "
|
||||
f"Client expires at: {expires_at} "
|
||||
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||
)
|
||||
|
||||
# Log if RFC 7592 fields are present
|
||||
has_reg_token = "registration_access_token" in client_info
|
||||
has_reg_uri = "registration_client_uri" in client_info
|
||||
if has_reg_token and has_reg_uri:
|
||||
logger.info(
|
||||
"RFC 7592 management fields received - client deletion will be supported"
|
||||
)
|
||||
else:
|
||||
logger.warning("RFC 7592 fields missing - client deletion may not work")
|
||||
|
||||
return ClientInfo(
|
||||
client_id=client_info["client_id"],
|
||||
client_secret=client_info["client_secret"],
|
||||
@@ -128,6 +157,8 @@ async def register_client(
|
||||
"client_secret_expires_at", int(time.time()) + 3600
|
||||
),
|
||||
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||
registration_access_token=client_info.get("registration_access_token"),
|
||||
registration_client_uri=client_info.get("registration_client_uri"),
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -205,12 +236,140 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
||||
raise
|
||||
|
||||
|
||||
async def delete_client(
|
||||
nextcloud_url: str,
|
||||
client_id: str,
|
||||
registration_access_token: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
registration_client_uri: str | None = None,
|
||||
max_retries: int = 3,
|
||||
) -> bool:
|
||||
"""
|
||||
Delete a dynamically registered OAuth client using RFC 7592.
|
||||
|
||||
This implements RFC 7592 Section 2.3 (Client Delete Request).
|
||||
Prefers Bearer token authentication (RFC 7592 standard) but falls back
|
||||
to HTTP Basic Auth if registration_access_token is not available.
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
client_id: Client identifier to delete
|
||||
registration_access_token: RFC 7592 registration access token (preferred)
|
||||
client_secret: Client secret for fallback HTTP Basic Auth
|
||||
registration_client_uri: RFC 7592 client configuration URI (optional)
|
||||
max_retries: Maximum number of retries for 429 responses (default: 3)
|
||||
|
||||
Returns:
|
||||
True if deletion successful, False otherwise
|
||||
|
||||
Note:
|
||||
RFC 7592 deletion endpoint: {registration_client_uri} or {nextcloud_url}/apps/oidc/register/{client_id}
|
||||
|
||||
Authentication methods (in order of preference):
|
||||
1. Bearer token: Authorization: Bearer {registration_access_token} (RFC 7592 standard)
|
||||
2. HTTP Basic Auth: client_id as username, client_secret as password (fallback)
|
||||
"""
|
||||
|
||||
# Determine deletion endpoint
|
||||
if registration_client_uri:
|
||||
deletion_endpoint = registration_client_uri
|
||||
else:
|
||||
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Prefer RFC 7592 Bearer token authentication
|
||||
if registration_access_token:
|
||||
logger.debug("Using RFC 7592 Bearer token authentication")
|
||||
response = await http_client.delete(
|
||||
deletion_endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {registration_access_token}"
|
||||
},
|
||||
)
|
||||
elif client_secret:
|
||||
logger.debug(
|
||||
"Falling back to HTTP Basic Auth (registration_access_token not available)"
|
||||
)
|
||||
response = await http_client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Cannot delete client: no registration_access_token or client_secret provided"
|
||||
)
|
||||
return False
|
||||
|
||||
# RFC 7592: Successful deletion returns 204 No Content
|
||||
if response.status_code == 204:
|
||||
logger.info(
|
||||
f"Successfully deleted OAuth client: {client_id[:16]}..."
|
||||
)
|
||||
return True
|
||||
elif response.status_code == 429:
|
||||
# Rate limited - retry with exponential backoff
|
||||
if attempt < max_retries - 1:
|
||||
retry_after = int(response.headers.get("Retry-After", 2))
|
||||
wait_time = min(
|
||||
retry_after, 2**attempt
|
||||
) # Exponential backoff, max from header
|
||||
logger.warning(
|
||||
f"Rate limited (429) deleting client {client_id[:16]}..., "
|
||||
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
await anyio.sleep(wait_time)
|
||||
continue
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}... after {max_retries} attempts: Rate limited (429)"
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 401:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
|
||||
)
|
||||
return False
|
||||
elif response.status_code == 403:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
|
||||
)
|
||||
logger.debug(f"Response: {response.text}")
|
||||
return False
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
|
||||
)
|
||||
logger.debug(f"Response: {e.response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error deleting client {client_id[:16]}...: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Should not reach here, but return False if we do
|
||||
return False
|
||||
|
||||
|
||||
async def load_or_register_client(
|
||||
nextcloud_url: str,
|
||||
registration_endpoint: str,
|
||||
storage_path: str | Path,
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Load client from storage or register a new one if not found/expired.
|
||||
@@ -227,6 +386,8 @@ async def load_or_register_client(
|
||||
storage_path: Path to store client credentials
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs
|
||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
|
||||
Returns:
|
||||
ClientInfo with valid credentials
|
||||
@@ -249,6 +410,8 @@ async def load_or_register_client(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
scopes=scopes,
|
||||
token_type=token_type,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
|
||||
@@ -0,0 +1,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., "notes:read", "notes:write")
|
||||
|
||||
Returns:
|
||||
Decorated function that checks scopes before execution
|
||||
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
# This tool requires the notes:read scope
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_create_note(ctx: Context, ...):
|
||||
# This tool requires the notes:write scope
|
||||
...
|
||||
```
|
||||
|
||||
Raises:
|
||||
ScopeAuthorizationError: If required scopes are not present in the access token
|
||||
"""
|
||||
|
||||
def decorator(func: Callable):
|
||||
# Store scope requirements as function metadata for dynamic filtering
|
||||
func._required_scopes = list(required_scopes) # type: ignore
|
||||
|
||||
# Find which parameter receives the Context (FastMCP injects it by name)
|
||||
context_param_name = find_context_parameter(func)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# Extract context from kwargs (where FastMCP injected it)
|
||||
ctx: Context | None = (
|
||||
kwargs.get(context_param_name) if context_param_name else None
|
||||
)
|
||||
|
||||
if ctx is None:
|
||||
# No context parameter found - likely BasicAuth mode
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Check if we're in OAuth mode (access token available)
|
||||
access_token: AccessToken | None = getattr(
|
||||
ctx.request_context, "access_token", None
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Extract scopes from access token
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if all required scopes are present
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
if missing_scopes:
|
||||
error_msg = (
|
||||
f"Access denied to {func.__name__}: "
|
||||
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
|
||||
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
|
||||
)
|
||||
logger.warning(error_msg)
|
||||
raise InsufficientScopeError(list(missing_scopes), error_msg)
|
||||
|
||||
# All required scopes present - allow execution
|
||||
logger.debug(
|
||||
f"Scope authorization passed for {func.__name__}: {required_scopes}"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
|
||||
"""
|
||||
Extract scopes from the authenticated user's access token.
|
||||
|
||||
This function uses MCP SDK's contextvar to access the token, which works
|
||||
across all request types including list_tools.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object (unused, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
Set of scope strings, empty set if no token or no scopes
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
# This works for all request types, including list_tools
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found in auth context (likely BasicAuth mode)")
|
||||
return set()
|
||||
|
||||
scopes = set(access_token.scopes or [])
|
||||
logger.info(f"✅ Extracted scopes from access token: {scopes}")
|
||||
return scopes
|
||||
|
||||
|
||||
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||
"""
|
||||
Check if the request context has all required scopes.
|
||||
|
||||
Utility function for manual scope checking without decorator.
|
||||
|
||||
Args:
|
||||
ctx: FastMCP context object
|
||||
*required_scopes: Variable number of required scope strings
|
||||
|
||||
Returns:
|
||||
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
|
||||
|
||||
Example:
|
||||
```python
|
||||
async def my_tool(ctx: Context):
|
||||
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||
if not has_scopes:
|
||||
# Handle missing scopes
|
||||
...
|
||||
```
|
||||
"""
|
||||
token_scopes = get_access_token_scopes(ctx)
|
||||
|
||||
# If no access token, assume BasicAuth mode (all operations allowed)
|
||||
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
|
||||
return True, set()
|
||||
|
||||
required_scopes_set = set(required_scopes)
|
||||
missing_scopes = required_scopes_set - token_scopes
|
||||
|
||||
return len(missing_scopes) == 0, missing_scopes
|
||||
|
||||
|
||||
def get_required_scopes(func: Callable) -> list[str]:
|
||||
"""
|
||||
Extract required scopes from a function decorated with @require_scopes.
|
||||
|
||||
Args:
|
||||
func: Function to check (may be decorated)
|
||||
|
||||
Returns:
|
||||
List of required scope strings, empty list if no scopes required
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
async def my_tool():
|
||||
pass
|
||||
|
||||
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||
```
|
||||
"""
|
||||
return getattr(func, "_required_scopes", [])
|
||||
|
||||
|
||||
def is_jwt_token() -> bool:
|
||||
"""
|
||||
Check if the current access token is in JWT format.
|
||||
|
||||
JWT tokens have 3 parts separated by dots (header.payload.signature).
|
||||
Opaque tokens are random strings without this structure.
|
||||
|
||||
Returns:
|
||||
True if current token is JWT format, False if opaque or no token
|
||||
"""
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if access_token is None:
|
||||
logger.debug("No access token found - not JWT")
|
||||
return False
|
||||
|
||||
# JWT tokens have exactly 2 dots (3 parts)
|
||||
token_string = access_token.token
|
||||
is_jwt = "." in token_string and token_string.count(".") == 2
|
||||
|
||||
logger.debug(f"Token format check: is_jwt={is_jwt}")
|
||||
return is_jwt
|
||||
|
||||
|
||||
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
"""
|
||||
Check if a user has all scopes required by a function.
|
||||
|
||||
Used for dynamic tool filtering - determines if a tool should be visible
|
||||
to a user based on their token scopes.
|
||||
|
||||
Args:
|
||||
func: Function decorated with @require_scopes
|
||||
user_scopes: Set of scopes the user possesses
|
||||
|
||||
Returns:
|
||||
True if user has all required scopes (or no scopes required), False otherwise
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
user_scopes = {"notes:read", "notes:write"}
|
||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||
|
||||
limited_user_scopes = {"notes:read"}
|
||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||
```
|
||||
"""
|
||||
required = get_required_scopes(func)
|
||||
|
||||
# No scopes required → always allow
|
||||
if not required:
|
||||
return True
|
||||
|
||||
# Empty user_scopes but scopes required → deny
|
||||
if not user_scopes:
|
||||
return False
|
||||
|
||||
# Check if user has all required scopes
|
||||
return set(required).issubset(user_scopes)
|
||||
@@ -5,6 +5,8 @@ import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||
|
||||
This verifier:
|
||||
1. Calls the userinfo endpoint with the bearer token
|
||||
2. Caches successful responses to avoid repeated API calls
|
||||
3. Extracts username from the 'sub' or 'preferred_username' claim
|
||||
4. Optionally supports JWT validation for performance (future enhancement)
|
||||
This verifier supports both JWT and opaque tokens:
|
||||
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
|
||||
2. For opaque tokens: Falls back to userinfo endpoint validation
|
||||
3. Caches successful responses to avoid repeated API calls/verifications
|
||||
|
||||
The userinfo endpoint validates the token and returns user claims if valid,
|
||||
or returns HTTP 400/401 if the token is invalid or expired.
|
||||
JWT validation provides:
|
||||
- Faster validation (no HTTP call needed)
|
||||
- Direct scope extraction from token payload
|
||||
- Signature verification using JWKS
|
||||
|
||||
Userinfo fallback provides:
|
||||
- Support for opaque tokens
|
||||
- Backward compatibility
|
||||
- Additional validation layer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
issuer: str | None = None,
|
||||
introspection_uri: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""
|
||||
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
Args:
|
||||
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||
userinfo_uri: Full URL to the userinfo endpoint
|
||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||
issuer: Expected issuer claim value (for JWT verification)
|
||||
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||
client_id: OAuth client ID (required for introspection)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||
"""
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Cache: token -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
# HTTP client for userinfo requests
|
||||
# HTTP client for userinfo/introspection requests
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# PyJWKClient for JWT verification (lazy initialization)
|
||||
self._jwks_client: PyJWKClient | None = None
|
||||
if jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support
|
||||
if introspection_uri and client_id and client_secret:
|
||||
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||
elif introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify a bearer token by calling the userinfo endpoint.
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Calls the userinfo endpoint if not cached
|
||||
3. Returns AccessToken with username stored in metadata
|
||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||
3. Falls back to introspection for opaque tokens (if configured)
|
||||
4. Falls back to userinfo endpoint as last resort
|
||||
5. Returns AccessToken with username and scopes
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
@@ -69,13 +108,232 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Validate via userinfo endpoint
|
||||
# Try JWT verification first if enabled and token looks like JWT
|
||||
is_jwt_format = self._is_jwt_format(token)
|
||||
logger.debug(
|
||||
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||
)
|
||||
if self._jwks_client and is_jwt_format:
|
||||
logger.debug("Attempting JWT verification...")
|
||||
jwt_result = self._verify_jwt(token)
|
||||
if jwt_result:
|
||||
logger.info("Token validated via JWT verification")
|
||||
return jwt_result
|
||||
else:
|
||||
logger.warning("JWT verification failed, will try other methods")
|
||||
|
||||
# For opaque tokens, try introspection if available
|
||||
if self.introspection_uri and self.client_id and self.client_secret:
|
||||
logger.debug("Attempting token introspection...")
|
||||
try:
|
||||
introspection_result = await self._verify_via_introspection(token)
|
||||
if introspection_result:
|
||||
logger.info("Token validated via introspection")
|
||||
return introspection_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Introspection failed: {e}")
|
||||
|
||||
# Fall back to userinfo endpoint validation (last resort)
|
||||
logger.debug("Attempting userinfo endpoint validation...")
|
||||
try:
|
||||
return await self._verify_via_userinfo(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_jwt_format(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: The JWT token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True if self.issuer else False,
|
||||
"verify_aud": False, # Skip audience validation for Bearer tokens
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim)
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
logger.error("No 'sub' claim found in JWT payload")
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("No 'exp' claim in JWT, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
This method validates opaque tokens and retrieves their scopes.
|
||||
|
||||
Args:
|
||||
token: The bearer token to introspect
|
||||
|
||||
Returns:
|
||||
AccessToken if active, None if inactive or invalid
|
||||
"""
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
|
||||
# Extract username
|
||||
username = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not username:
|
||||
logger.error("No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||
|
||||
# Extract expiration
|
||||
exp = introspection_data.get("exp")
|
||||
if exp:
|
||||
expiry = float(exp)
|
||||
else:
|
||||
logger.warning(
|
||||
"No 'exp' in introspection response, using default TTL"
|
||||
)
|
||||
expiry = time.time() + self.cache_ttl
|
||||
|
||||
# Cache the result
|
||||
cache_data = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{
|
||||
k: v
|
||||
for k, v in introspection_data.items()
|
||||
if k not in ["sub", "scope", "active"]
|
||||
},
|
||||
}
|
||||
self._token_cache[token] = (cache_data, expiry)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=introspection_data.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
|
||||
f"token issued to different OAuth client, (2) Expired client credentials, "
|
||||
f"(3) Invalid token. Will fall back to userinfo endpoint. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the userinfo endpoint.
|
||||
@@ -169,15 +427,31 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Extract scopes from userinfo response.
|
||||
|
||||
Since the userinfo response doesn't include the original scopes,
|
||||
we infer them from the claims present in the response.
|
||||
First attempts to read actual scopes from the 'scope' field (RFC 8693).
|
||||
If not present, infers scopes from the claims present in the response.
|
||||
|
||||
Args:
|
||||
userinfo: The userinfo response dictionary
|
||||
|
||||
Returns:
|
||||
List of inferred scopes
|
||||
List of scopes (actual or inferred)
|
||||
"""
|
||||
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
|
||||
scope_string = userinfo.get("scope")
|
||||
if scope_string:
|
||||
scopes = scope_string.split() if isinstance(scope_string, str) else []
|
||||
if scopes:
|
||||
logger.debug(
|
||||
f"Using actual scopes from userinfo: {scopes} (scope field present)"
|
||||
)
|
||||
return scopes
|
||||
|
||||
# Fallback: Infer scopes from claims present in response
|
||||
# This maintains backward compatibility with OIDC providers that don't
|
||||
# include the scope field in userinfo responses
|
||||
logger.debug(
|
||||
"No scope field in userinfo response, inferring scopes from claims"
|
||||
)
|
||||
scopes = ["openid"] # Always present
|
||||
|
||||
if "email" in userinfo:
|
||||
@@ -194,6 +468,7 @@ class NextcloudTokenVerifier(TokenVerifier):
|
||||
if "groups" in userinfo:
|
||||
scopes.append("groups")
|
||||
|
||||
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||
return scopes
|
||||
|
||||
def clear_cache(self):
|
||||
|
||||
@@ -72,7 +72,9 @@ 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)
|
||||
@@ -129,5 +131,6 @@ class NextcloudClient:
|
||||
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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import BaseResponse, IdResponse, StatusResponse
|
||||
|
||||
@@ -38,8 +38,7 @@ class Nutrition(BaseModel):
|
||||
None, description="Unsaturated fat (e.g., '40 g')"
|
||||
)
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class RecipeStub(BaseModel):
|
||||
@@ -91,9 +90,7 @@ class Recipe(BaseModel):
|
||||
)
|
||||
nutrition: Optional[Nutrition] = Field(None, description="Nutrition information")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
extra = "allow" # Allow additional schema.org fields
|
||||
model_config = ConfigDict(populate_by_name=True, extra="allow")
|
||||
|
||||
|
||||
class Category(BaseModel):
|
||||
@@ -127,8 +124,7 @@ class VisibleInfoBlocks(BaseModel):
|
||||
)
|
||||
tools: Optional[bool] = Field(None, description="Show tools list")
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
|
||||
class CookbookConfig(BaseModel):
|
||||
|
||||
@@ -40,7 +40,7 @@ class DirectoryListing(BaseResponse):
|
||||
"""Response model for directory listings."""
|
||||
|
||||
path: str = Field(description="Directory path")
|
||||
items: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
files: List[FileInfo] = Field(description="Files and directories in the path")
|
||||
total_count: int = Field(description="Total number of items")
|
||||
directories_count: int = Field(description="Number of directories")
|
||||
files_count: int = Field(description="Number of files")
|
||||
|
||||
@@ -4,8 +4,14 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import Calendar, ListCalendarsResponse
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
ListCalendarsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,6 +19,7 @@ logger = logging.getLogger(__name__)
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
"""List all available calendars for the user"""
|
||||
client = get_client(ctx)
|
||||
@@ -22,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_create_event(
|
||||
calendar_name: str,
|
||||
title: str,
|
||||
@@ -97,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_list_events(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
@@ -198,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_get_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -209,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_update_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -281,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_delete_event(
|
||||
calendar_name: str,
|
||||
event_uid: str,
|
||||
@@ -291,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_create_meeting(
|
||||
title: str,
|
||||
date: str,
|
||||
@@ -356,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
ctx: Context,
|
||||
calendar_name: str = "", # Empty = all calendars
|
||||
@@ -405,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:read")
|
||||
async def nc_calendar_find_availability(
|
||||
duration_minutes: int,
|
||||
ctx: Context,
|
||||
@@ -484,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_bulk_operations(
|
||||
operation: str, # "update", "delete", "move"
|
||||
ctx: Context,
|
||||
@@ -732,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("calendar:write")
|
||||
async def nc_calendar_manage_calendar(
|
||||
action: str, # "create", "delete", "update", "list"
|
||||
ctx: Context,
|
||||
@@ -796,3 +813,214 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
else:
|
||||
raise ValueError("Action must be 'create', 'delete', 'update', or 'list'")
|
||||
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
async def nc_calendar_list_todos(
|
||||
calendar_name: str,
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
min_priority: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
summary_contains: Optional[str] = None,
|
||||
) -> ListTodosResponse:
|
||||
"""List todos/tasks in a calendar with optional filtering.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to list todos from
|
||||
ctx: MCP context
|
||||
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||
summary_contains: Filter todos where summary contains this text
|
||||
|
||||
Returns:
|
||||
List of todos matching the filters
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if min_priority is not None:
|
||||
filters["min_priority"] = min_priority
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if summary_contains is not None:
|
||||
filters["summary_contains"] = summary_contains
|
||||
|
||||
todos_data = await client.calendar.list_todos(
|
||||
calendar_name, filters if filters else None
|
||||
)
|
||||
|
||||
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||
return ListTodosResponse(
|
||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_create_todo(
|
||||
calendar_name: str,
|
||||
summary: str,
|
||||
ctx: Context,
|
||||
description: str = "",
|
||||
status: str = "NEEDS-ACTION",
|
||||
priority: int = 0,
|
||||
due: str = "",
|
||||
dtstart: str = "",
|
||||
categories: str = "",
|
||||
):
|
||||
"""Create a new todo/task in a calendar.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar to create the todo in
|
||||
summary: Todo title/summary
|
||||
ctx: MCP context
|
||||
description: Detailed description of the todo
|
||||
status: Todo status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
priority: Priority (0=undefined, 1=highest, 9=lowest)
|
||||
due: Due date/time (ISO format, e.g., "2025-01-15T14:00:00")
|
||||
dtstart: Start date/time (ISO format)
|
||||
categories: Comma-separated categories (e.g., "work,urgent")
|
||||
|
||||
Returns:
|
||||
Dict with todo creation result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
todo_data = {
|
||||
"summary": summary,
|
||||
"description": description,
|
||||
"status": status,
|
||||
"priority": priority,
|
||||
"due": due,
|
||||
"dtstart": dtstart,
|
||||
"categories": categories,
|
||||
}
|
||||
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_update_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
ctx: Context,
|
||||
summary: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
percent_complete: Optional[int] = None,
|
||||
due: Optional[str] = None,
|
||||
dtstart: Optional[str] = None,
|
||||
completed: Optional[str] = None,
|
||||
categories: Optional[str] = None,
|
||||
):
|
||||
"""Update an existing todo/task.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar containing the todo
|
||||
todo_uid: UID of the todo to update
|
||||
ctx: MCP context
|
||||
summary: New summary/title
|
||||
description: New description
|
||||
status: New status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
priority: New priority (0-9)
|
||||
percent_complete: New completion percentage (0-100)
|
||||
due: New due date/time (ISO format)
|
||||
dtstart: New start date/time (ISO format)
|
||||
completed: Completion timestamp (ISO format)
|
||||
categories: New categories (comma-separated)
|
||||
|
||||
Returns:
|
||||
Dict with todo update result
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build update data with only non-None values
|
||||
todo_data = {}
|
||||
if summary is not None:
|
||||
todo_data["summary"] = summary
|
||||
if description is not None:
|
||||
todo_data["description"] = description
|
||||
if status is not None:
|
||||
todo_data["status"] = status
|
||||
if priority is not None:
|
||||
todo_data["priority"] = priority
|
||||
if percent_complete is not None:
|
||||
todo_data["percent_complete"] = percent_complete
|
||||
if due is not None:
|
||||
todo_data["due"] = due
|
||||
if dtstart is not None:
|
||||
todo_data["dtstart"] = dtstart
|
||||
if completed is not None:
|
||||
todo_data["completed"] = completed
|
||||
if categories is not None:
|
||||
todo_data["categories"] = categories
|
||||
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
async def nc_calendar_delete_todo(
|
||||
calendar_name: str,
|
||||
todo_uid: str,
|
||||
ctx: Context,
|
||||
):
|
||||
"""Delete a todo/task from a calendar.
|
||||
|
||||
Args:
|
||||
calendar_name: Name of the calendar containing the todo
|
||||
todo_uid: UID of the todo to delete
|
||||
ctx: MCP context
|
||||
|
||||
Returns:
|
||||
Dict with deletion status
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
async def nc_calendar_search_todos(
|
||||
ctx: Context,
|
||||
status: Optional[str] = None,
|
||||
min_priority: Optional[int] = None,
|
||||
categories: Optional[str] = None,
|
||||
summary_contains: Optional[str] = None,
|
||||
):
|
||||
"""Search todos across all calendars with optional filtering.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
status: Filter by status (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
|
||||
min_priority: Filter by minimum priority (1=highest, 9=lowest)
|
||||
categories: Filter by categories (comma-separated, e.g., "work,urgent")
|
||||
summary_contains: Filter todos where summary contains this text
|
||||
|
||||
Returns:
|
||||
List of todos matching the filters from all calendars
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
|
||||
# Build filters dictionary
|
||||
filters = {}
|
||||
if status is not None:
|
||||
filters["status"] = status
|
||||
if min_priority is not None:
|
||||
filters["min_priority"] = min_priority
|
||||
if categories is not None:
|
||||
filters["categories"] = [cat.strip() for cat in categories.split(",")]
|
||||
if summary_contains is not None:
|
||||
filters["summary_contains"] = summary_contains
|
||||
|
||||
todos_data = await client.calendar.search_todos_across_calendars(
|
||||
filters if filters else None
|
||||
)
|
||||
|
||||
todos = [Todo(**todo_data) for todo_data in todos_data]
|
||||
return ListTodosResponse(todos=todos, total_count=len(todos))
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
"""List all addressbooks for the user."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:read")
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_create_addressbook(
|
||||
ctx: Context, *, name: str, display_name: str
|
||||
):
|
||||
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
"""Delete an addressbook."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_create_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||
):
|
||||
@@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
client = get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("contacts:write")
|
||||
async def nc_contacts_update_contact(
|
||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
):
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.cookbook import (
|
||||
Category,
|
||||
@@ -70,6 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||
"""Import a recipe from a URL using schema.org metadata.
|
||||
|
||||
@@ -126,6 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||
"""Get all recipes in the database"""
|
||||
client = get_client(ctx)
|
||||
@@ -150,6 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||
"""Get a specific recipe by its ID"""
|
||||
client = get_client(ctx)
|
||||
@@ -174,6 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_create_recipe(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
@@ -252,6 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_update_recipe(
|
||||
recipe_id: int,
|
||||
name: str | None = None,
|
||||
@@ -340,6 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_delete_recipe(
|
||||
recipe_id: int, ctx: Context
|
||||
) -> DeleteRecipeResponse:
|
||||
@@ -374,6 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_search_recipes(
|
||||
query: str, ctx: Context
|
||||
) -> SearchRecipesResponse:
|
||||
@@ -409,6 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||
"""Get all known categories.
|
||||
|
||||
@@ -435,6 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipes_in_category(
|
||||
category: str, ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -470,6 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||
"""Get all known keywords/tags"""
|
||||
client = get_client(ctx)
|
||||
@@ -494,6 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:read")
|
||||
async def nc_cookbook_get_recipes_with_keywords(
|
||||
keywords: list[str], ctx: Context
|
||||
) -> ListRecipesResponse:
|
||||
@@ -527,6 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_set_config(
|
||||
folder: str | None = None,
|
||||
update_interval: int | None = None,
|
||||
@@ -569,6 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("cookbook:write")
|
||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||
"""Trigger a rescan of all recipes into the caching database.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
CardOperationResponse,
|
||||
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Read Tools (converted from resources)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = get_client(ctx)
|
||||
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return boards
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||
"""Get details of a specific Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stacks
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||
"""Get details of a specific Nextcloud Deck stack"""
|
||||
client = get_client(ctx)
|
||||
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return stack
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return []
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> DeckCard:
|
||||
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return card
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = get_client(ctx)
|
||||
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return board.labels
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:read")
|
||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||
"""Get details of a specific Nextcloud Deck label"""
|
||||
client = get_client(ctx)
|
||||
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Create/Update/Delete Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_board(
|
||||
ctx: Context, title: str, color: str
|
||||
) -> CreateBoardResponse:
|
||||
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
# Stack Tools
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_stack(
|
||||
ctx: Context, board_id: int, title: str, order: int
|
||||
) -> CreateStackResponse:
|
||||
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_stack(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_stack(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> StackOperationResponse:
|
||||
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_archive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_unarchive_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_reorder_card(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Label Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_create_label(
|
||||
ctx: Context, board_id: int, title: str, color: str
|
||||
) -> CreateLabelResponse:
|
||||
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_update_label(
|
||||
ctx: Context,
|
||||
board_id: int,
|
||||
@@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_delete_label(
|
||||
ctx: Context, board_id: int, label_id: int
|
||||
) -> LabelOperationResponse:
|
||||
@@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-Label Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_assign_label_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_remove_label_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||
) -> CardOperationResponse:
|
||||
@@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
# Card-User Assignment Tools
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_assign_user_to_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
@@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("deck:write")
|
||||
async def deck_unassign_user_from_card(
|
||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||
) -> CardOperationResponse:
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
AppendContentResponse,
|
||||
@@ -84,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_create_note(
|
||||
title: str, content: str, category: str, ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
"""Create a new note"""
|
||||
"""Create a new note (requires notes:write scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.create_note(
|
||||
@@ -129,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_update_note(
|
||||
note_id: int,
|
||||
etag: str,
|
||||
@@ -137,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
category: str | None,
|
||||
ctx: Context,
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
"""Update an existing note's title, content, or category (requires notes:write scope).
|
||||
|
||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||
@@ -193,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_append_content(
|
||||
note_id: int, content: str, ctx: Context
|
||||
) -> AppendContentResponse:
|
||||
@@ -242,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning only id, title, and category."""
|
||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
search_results_raw = await client.notes_search_notes(query=query)
|
||||
@@ -287,8 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||
"""Get a specific note by its ID"""
|
||||
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||
client = get_client(ctx)
|
||||
try:
|
||||
note_data = await client.notes.get_note(note_id)
|
||||
@@ -315,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_get_attachment(
|
||||
note_id: int, attachment_filename: str, ctx: Context
|
||||
) -> dict[str, str]:
|
||||
@@ -360,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently"""
|
||||
logger.info("Deleting note %s", note_id)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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
|
||||
|
||||
|
||||
@@ -15,6 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_create(
|
||||
path: str,
|
||||
share_with: str,
|
||||
@@ -53,6 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||
"""Delete a share by its ID.
|
||||
|
||||
@@ -71,6 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||
"""Get information about a specific share.
|
||||
|
||||
@@ -88,6 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(share_data, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_list(
|
||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||
) -> str:
|
||||
@@ -108,6 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
||||
return json.dumps(shares, indent=2)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("sharing:write")
|
||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||
"""Update the permissions of an existing share.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
|
||||
def configure_tables_tools(mcp: FastMCP):
|
||||
# Tables tools
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
"""List all tables available to the user"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||
"""Get the schema/structure of a specific table including columns and views"""
|
||||
client = get_client(ctx)
|
||||
return await client.tables.get_table_schema(table_id)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:read")
|
||||
async def nc_tables_read_table(
|
||||
table_id: int,
|
||||
ctx: Context,
|
||||
@@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||
"""Insert a new row into a table.
|
||||
|
||||
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.create_row(table_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||
"""Update an existing row in a table.
|
||||
|
||||
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
return await client.tables.update_row(row_id, data)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("tables:write")
|
||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||
"""Delete a row from a table"""
|
||||
client = get_client(ctx)
|
||||
|
||||
@@ -2,8 +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
|
||||
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,19 +12,40 @@ logger = logging.getLogger(__name__)
|
||||
def configure_webdav_tools(mcp: FastMCP):
|
||||
# WebDAV file system tools
|
||||
@mcp.tool()
|
||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_list_directory(
|
||||
ctx: Context, path: str = ""
|
||||
) -> DirectoryListing:
|
||||
"""List files and directories in the specified NextCloud path.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
Returns:
|
||||
List of items with metadata including name, path, is_directory, size, content_type, last_modified
|
||||
DirectoryListing with files, total_count, directories_count, files_count, and total_size
|
||||
"""
|
||||
client = get_client(ctx)
|
||||
return await client.webdav.list_directory(path)
|
||||
items = await client.webdav.list_directory(path)
|
||||
|
||||
# Convert to FileInfo models
|
||||
file_infos = [FileInfo(**item) for item in items]
|
||||
|
||||
# Calculate metadata
|
||||
directories_count = sum(1 for f in file_infos if f.is_directory)
|
||||
files_count = sum(1 for f in file_infos if not f.is_directory)
|
||||
total_size = sum(f.size or 0 for f in file_infos if not f.is_directory)
|
||||
|
||||
return DirectoryListing(
|
||||
path=path,
|
||||
files=file_infos,
|
||||
total_count=len(file_infos),
|
||||
directories_count=directories_count,
|
||||
files_count=files_count,
|
||||
total_size=total_size,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
@@ -62,6 +84,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_write_file(
|
||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||
):
|
||||
@@ -89,6 +112,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
@@ -102,6 +126,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
@@ -115,6 +140,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_move_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -134,6 +160,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:write")
|
||||
async def nc_webdav_copy_resource(
|
||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||
):
|
||||
@@ -153,6 +180,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_search_files(
|
||||
ctx: Context,
|
||||
scope: str = "",
|
||||
@@ -268,6 +296,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_find_by_name(
|
||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -294,6 +323,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_find_by_type(
|
||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
@@ -320,6 +350,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("files:read")
|
||||
async def nc_webdav_list_favorites(
|
||||
ctx: Context, scope: str = "", limit: int | None = None
|
||||
) -> SearchFilesResponse:
|
||||
|
||||
+49
-11
@@ -1,31 +1,55 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.15.2"
|
||||
description = ""
|
||||
version = "0.20.0"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
]
|
||||
readme = "README.md"
|
||||
license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.18,<1.19)",
|
||||
"mcp[cli] (>=1.19,<1.20)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
"pythonvcard4>=0.2.0",
|
||||
"pydantic>=2.11.4",
|
||||
"click>=8.1.8",
|
||||
"caldav",
|
||||
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Communications",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
|
||||
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
||||
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
|
||||
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
anyio_mode = "auto"
|
||||
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
||||
log_cli = 1
|
||||
log_cli_level = "WARN"
|
||||
log_level = "WARN"
|
||||
log_cli_level = "ERROR"
|
||||
log_level = "ERROR"
|
||||
markers = [
|
||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
||||
"unit: Fast unit tests with mocked dependencies",
|
||||
"integration: Integration tests requiring Docker containers",
|
||||
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||
"smoke: Critical path smoke tests for quick validation",
|
||||
]
|
||||
testpaths = [
|
||||
"tests",
|
||||
@@ -45,9 +69,16 @@ 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 = [
|
||||
@@ -56,6 +87,7 @@ dev = [
|
||||
"playwright>=1.49.1",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-cov>=6.1.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
"pytest-playwright-asyncio>=0.7.1",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"ruff>=0.11.13",
|
||||
@@ -63,3 +95,9 @@ dev = [
|
||||
|
||||
[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,482 @@
|
||||
import httpx
|
||||
|
||||
# ============================================================================
|
||||
# Mock Response Helpers for Unit Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def create_mock_response(
|
||||
status_code: int = 200,
|
||||
json_data: dict | list | None = None,
|
||||
headers: dict | None = None,
|
||||
content: bytes | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock httpx.Response for testing.
|
||||
|
||||
Args:
|
||||
status_code: HTTP status code
|
||||
json_data: JSON data to return from response.json()
|
||||
headers: Response headers
|
||||
content: Raw response content (if not using json_data)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response object
|
||||
"""
|
||||
import json as json_module
|
||||
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# If json_data is provided, serialize it to content
|
||||
if json_data is not None:
|
||||
content = json_module.dumps(json_data).encode("utf-8")
|
||||
headers.setdefault("content-type", "application/json")
|
||||
|
||||
if content is None:
|
||||
content = b""
|
||||
|
||||
# Create a mock request
|
||||
request = httpx.Request("GET", "http://test.local/api")
|
||||
|
||||
# Create the response
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
headers=headers,
|
||||
content=content,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_note_response(
|
||||
note_id: int = 1,
|
||||
title: str = "Test Note",
|
||||
content: str = "Test content",
|
||||
category: str = "Test",
|
||||
etag: str = "abc123",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud note.
|
||||
|
||||
Args:
|
||||
note_id: Note ID
|
||||
title: Note title
|
||||
content: Note content
|
||||
category: Note category
|
||||
etag: ETag header value
|
||||
**kwargs: Additional note fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with note data
|
||||
"""
|
||||
note_data = {
|
||||
"id": note_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"category": category,
|
||||
"etag": etag,
|
||||
"modified": 1234567890,
|
||||
"favorite": False,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=note_data,
|
||||
headers={"etag": f'"{etag}"'},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_error_response(
|
||||
status_code: int,
|
||||
message: str = "Error",
|
||||
) -> httpx.Response:
|
||||
"""Create a mock error response.
|
||||
|
||||
Args:
|
||||
status_code: HTTP error status code (e.g., 404, 412)
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with error
|
||||
"""
|
||||
return create_mock_response(
|
||||
status_code=status_code,
|
||||
json_data={"message": message},
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_response(
|
||||
recipe_id: int = 1,
|
||||
name: str = "Test Recipe",
|
||||
description: str = "Test description",
|
||||
recipe_category: str = "Test",
|
||||
keywords: str = "test",
|
||||
recipe_yield: int = 4,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Cookbook recipe.
|
||||
|
||||
Args:
|
||||
recipe_id: Recipe ID
|
||||
name: Recipe name
|
||||
description: Recipe description
|
||||
recipe_category: Recipe category
|
||||
keywords: Recipe keywords (comma-separated)
|
||||
recipe_yield: Recipe yield (number of servings)
|
||||
**kwargs: Additional recipe fields (recipeIngredient, recipeInstructions, etc.)
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe data
|
||||
"""
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"recipeCategory": recipe_category,
|
||||
"keywords": keywords,
|
||||
"recipeYield": recipe_yield,
|
||||
"recipeIngredient": kwargs.get("recipeIngredient", []),
|
||||
"recipeInstructions": kwargs.get("recipeInstructions", []),
|
||||
"prepTime": kwargs.get("prepTime", "PT15M"),
|
||||
"cookTime": kwargs.get("cookTime", "PT30M"),
|
||||
"totalTime": kwargs.get("totalTime", "PT45M"),
|
||||
"url": kwargs.get("url", ""),
|
||||
**{
|
||||
k: v
|
||||
for k, v in kwargs.items()
|
||||
if k
|
||||
not in [
|
||||
"recipeIngredient",
|
||||
"recipeInstructions",
|
||||
"prepTime",
|
||||
"cookTime",
|
||||
"totalTime",
|
||||
"url",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipe_data,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_recipe_list_response(
|
||||
recipes: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a list of recipe stubs.
|
||||
|
||||
Args:
|
||||
recipes: List of recipe stub dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with recipe list data
|
||||
"""
|
||||
if recipes is None:
|
||||
recipes = []
|
||||
|
||||
return create_mock_response(
|
||||
status_code=200,
|
||||
json_data=recipes,
|
||||
)
|
||||
|
||||
|
||||
def create_mock_deck_board_response(
|
||||
board_id: int = 1,
|
||||
title: str = "Test Board",
|
||||
color: str = "0000FF",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck board.
|
||||
|
||||
Args:
|
||||
board_id: Board ID
|
||||
title: Board title
|
||||
color: Board color (hex without #)
|
||||
**kwargs: Additional board fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with board data
|
||||
"""
|
||||
board_data = {
|
||||
"id": board_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=board_data)
|
||||
|
||||
|
||||
def create_mock_deck_stack_response(
|
||||
stack_id: int = 1,
|
||||
title: str = "Test Stack",
|
||||
board_id: int = 1,
|
||||
order: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck stack.
|
||||
|
||||
Args:
|
||||
stack_id: Stack ID
|
||||
title: Stack title
|
||||
board_id: Parent board ID
|
||||
order: Stack order
|
||||
**kwargs: Additional stack fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with stack data
|
||||
"""
|
||||
stack_data = {
|
||||
"id": stack_id,
|
||||
"title": title,
|
||||
"boardId": board_id,
|
||||
"order": order,
|
||||
"deletedAt": 0,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=stack_data)
|
||||
|
||||
|
||||
def create_mock_deck_card_response(
|
||||
card_id: int = 1,
|
||||
title: str = "Test Card",
|
||||
stack_id: int = 1,
|
||||
description: str = "Test description",
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck card.
|
||||
|
||||
Args:
|
||||
card_id: Card ID
|
||||
title: Card title
|
||||
stack_id: Parent stack ID
|
||||
description: Card description
|
||||
**kwargs: Additional card fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with card data
|
||||
"""
|
||||
card_data = {
|
||||
"id": card_id,
|
||||
"title": title,
|
||||
"stackId": stack_id,
|
||||
"type": "plain",
|
||||
"order": 999,
|
||||
"archived": False,
|
||||
"owner": "testuser",
|
||||
"description": description,
|
||||
"labels": [],
|
||||
"assignedUsers": [],
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=card_data)
|
||||
|
||||
|
||||
def create_mock_deck_label_response(
|
||||
label_id: int = 1,
|
||||
title: str = "Test Label",
|
||||
color: str = "FF0000",
|
||||
board_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck label.
|
||||
|
||||
Args:
|
||||
label_id: Label ID
|
||||
title: Label title
|
||||
color: Label color (hex without #)
|
||||
board_id: Parent board ID
|
||||
**kwargs: Additional label fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with label data
|
||||
"""
|
||||
label_data = {
|
||||
"id": label_id,
|
||||
"title": title,
|
||||
"color": color,
|
||||
"boardId": board_id,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=label_data)
|
||||
|
||||
|
||||
def create_mock_deck_comment_response(
|
||||
comment_id: int = 1,
|
||||
message: str = "Test comment",
|
||||
card_id: int = 1,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for a Nextcloud Deck comment (OCS format).
|
||||
|
||||
Args:
|
||||
comment_id: Comment ID
|
||||
message: Comment message
|
||||
card_id: Parent card ID
|
||||
**kwargs: Additional comment fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with comment data in OCS format
|
||||
"""
|
||||
comment_data = {
|
||||
"id": comment_id,
|
||||
"objectId": card_id,
|
||||
"message": message,
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [], # Required field
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
# Wrap in OCS format
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": comment_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_tables_list_response(
|
||||
tables: list[dict] = None,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for list of Nextcloud Tables (OCS format).
|
||||
|
||||
Args:
|
||||
tables: List of table dictionaries. If None, returns empty list.
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with tables list data in OCS format
|
||||
"""
|
||||
if tables is None:
|
||||
tables = []
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": tables}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
|
||||
|
||||
def create_mock_table_schema_response(
|
||||
table_id: int = 1,
|
||||
columns: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables schema.
|
||||
|
||||
Args:
|
||||
table_id: Table ID
|
||||
columns: List of column definitions. If None, creates sample columns.
|
||||
**kwargs: Additional schema fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with table schema data
|
||||
"""
|
||||
if columns is None:
|
||||
columns = [
|
||||
{"id": 1, "title": "Column 1", "type": "text"},
|
||||
{"id": 2, "title": "Column 2", "type": "number"},
|
||||
]
|
||||
|
||||
schema_data = {
|
||||
"id": table_id,
|
||||
"columns": columns,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=schema_data)
|
||||
|
||||
|
||||
def create_mock_table_row_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock response for Nextcloud Tables row.
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=row_data)
|
||||
|
||||
|
||||
def create_mock_table_row_ocs_response(
|
||||
row_id: int = 1,
|
||||
table_id: int = 1,
|
||||
data: list[dict] = None,
|
||||
**kwargs,
|
||||
) -> httpx.Response:
|
||||
"""Create a mock OCS response for Nextcloud Tables row (used by create_row).
|
||||
|
||||
Args:
|
||||
row_id: Row ID
|
||||
table_id: Table ID
|
||||
data: List of column data dicts. If None, creates sample data.
|
||||
**kwargs: Additional row fields
|
||||
|
||||
Returns:
|
||||
Mock httpx.Response with row data in OCS format
|
||||
"""
|
||||
if data is None:
|
||||
data = [
|
||||
{"columnId": 1, "value": "Test value"},
|
||||
{"columnId": 2, "value": 42},
|
||||
]
|
||||
|
||||
row_data = {
|
||||
"id": row_id,
|
||||
"tableId": table_id,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": data,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||
|
||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
||||
@@ -1,386 +1,371 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.cookbook import CookbookClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_recipe_list_response,
|
||||
create_mock_recipe_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
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()
|
||||
async def test_cookbook_version(mocker):
|
||||
"""Test that get_version correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"cookbook_version": "1.0.0",
|
||||
"api_version": "1.0.0",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
version_data = await client.get_version()
|
||||
|
||||
assert "cookbook_version" in version_data
|
||||
assert "api_version" in version_data
|
||||
logger.info(f"Cookbook version: {version_data}")
|
||||
assert version_data["cookbook_version"] == "1.0.0"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/version")
|
||||
|
||||
|
||||
async def test_cookbook_config(nc_client: NextcloudClient):
|
||||
"""Test getting Cookbook app configuration."""
|
||||
logger.info("Getting Cookbook app configuration")
|
||||
config_data = await nc_client.cookbook.get_config()
|
||||
async def test_cookbook_config(mocker):
|
||||
"""Test that get_config correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"folder": "/recipes",
|
||||
"update_interval": 60,
|
||||
"print_image": True,
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
config_data = await client.get_config()
|
||||
|
||||
# Config may be empty initially, just verify we can get it
|
||||
assert isinstance(config_data, dict)
|
||||
logger.info(f"Cookbook config: {config_data}")
|
||||
assert config_data["folder"] == "/recipes"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/config")
|
||||
|
||||
|
||||
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()
|
||||
async def test_cookbook_list_recipes(mocker):
|
||||
"""Test that list_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Test"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Test"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes = await client.list_recipes()
|
||||
|
||||
assert isinstance(recipes, list)
|
||||
logger.info(f"Found {len(recipes)} recipes")
|
||||
assert len(recipes) == 2
|
||||
assert recipes[0]["name"] == "Recipe 1"
|
||||
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/recipes")
|
||||
|
||||
|
||||
async def test_cookbook_create_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]}"
|
||||
async def test_cookbook_create_recipe(mocker):
|
||||
"""Test that create_recipe correctly parses the API response."""
|
||||
# Create_recipe returns just the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe_data = {
|
||||
"name": 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",
|
||||
"name": "Test Recipe",
|
||||
"description": "Test description",
|
||||
"recipeIngredient": ["100g flour"],
|
||||
"recipeInstructions": ["Mix ingredients"],
|
||||
"recipeCategory": "Original",
|
||||
}
|
||||
recipe_id = await client.create_recipe(recipe_data)
|
||||
|
||||
logger.info(f"Creating recipe for update test: {recipe_name}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert recipe_id == 123
|
||||
|
||||
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)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST", "/apps/cookbook/api/v1/recipes", json=recipe_data
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient):
|
||||
"""Test deleting a non-existent recipe.
|
||||
async def test_cookbook_get_recipe(mocker):
|
||||
"""Test that get_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_response(
|
||||
recipe_id=123,
|
||||
name="Test Recipe",
|
||||
description="Test description",
|
||||
recipe_category="Test",
|
||||
keywords="test,integration",
|
||||
recipe_yield=4,
|
||||
recipeIngredient=["100g flour", "2 eggs"],
|
||||
recipeInstructions=["Mix ingredients", "Cook"],
|
||||
)
|
||||
|
||||
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
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
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}")
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipe = await client.get_recipe(recipe_id=123)
|
||||
|
||||
assert recipe["id"] == 123
|
||||
assert recipe["name"] == "Test Recipe"
|
||||
assert recipe["description"] == "Test description"
|
||||
assert len(recipe["recipeIngredient"]) == 2
|
||||
assert len(recipe["recipeInstructions"]) == 2
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"GET", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient):
|
||||
"""Test importing a recipe from a URL.
|
||||
async def test_cookbook_update_recipe(mocker):
|
||||
"""Test that update_recipe correctly parses the API response."""
|
||||
# Update_recipe returns the recipe ID
|
||||
mock_response = create_mock_response(status_code=200, json_data=123)
|
||||
|
||||
This is the key feature test - importing recipes from URLs using schema.org metadata.
|
||||
Uses an nginx container to serve reliable, controlled test data.
|
||||
"""
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# 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"],
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
updated_data = {
|
||||
"name": "Updated Recipe",
|
||||
"description": "Updated description",
|
||||
"recipeIngredient": ["100g flour", "2 eggs", "200ml milk"],
|
||||
"recipeInstructions": ["Mix ingredients", "Cook", "Serve"],
|
||||
}
|
||||
updated_id = await client.update_recipe(recipe_id=123, recipe_data=updated_data)
|
||||
|
||||
logger.info(f"Creating recipe for search test with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
assert updated_id == 123
|
||||
|
||||
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)
|
||||
mock_make_request.assert_called_once_with(
|
||||
"PUT", "/apps/cookbook/api/v1/recipes/123", json=updated_data
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
async def test_cookbook_delete_recipe(mocker):
|
||||
"""Test that delete_recipe correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data="Recipe deleted successfully"
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.delete_recipe(recipe_id=123)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "deleted" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with(
|
||||
"DELETE", "/apps/cookbook/api/v1/recipes/123"
|
||||
)
|
||||
|
||||
|
||||
async def test_cookbook_delete_nonexistent_recipe(mocker):
|
||||
"""Test that deleting a non-existent recipe raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Recipe not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(CookbookClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_recipe(recipe_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
async def test_cookbook_search_recipes(mocker):
|
||||
"""Test that search_recipes correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Test Recipe 1", "keywords": "test,search"},
|
||||
{"id": 2, "name": "Test Recipe 2", "keywords": "test,search"},
|
||||
]
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
search_results = await client.search_recipes("test")
|
||||
|
||||
assert isinstance(search_results, list)
|
||||
assert len(search_results) == 2
|
||||
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/search/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_categories(mocker):
|
||||
"""Test that list_categories correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "Desserts", "recipe_count": 5},
|
||||
{"name": "Main Course", "recipe_count": 10},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
categories = await client.list_categories()
|
||||
|
||||
assert isinstance(categories, list)
|
||||
logger.info(f"Found {len(categories)} categories")
|
||||
assert len(categories) == 2
|
||||
assert categories[0]["name"] == "Desserts"
|
||||
assert categories[0]["recipe_count"] == 5
|
||||
|
||||
# Each category should have name and recipe_count
|
||||
if categories:
|
||||
assert "name" in categories[0]
|
||||
assert "recipe_count" in categories[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/categories")
|
||||
|
||||
|
||||
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"],
|
||||
}
|
||||
async def test_cookbook_get_recipes_in_category(mocker):
|
||||
"""Test that get_recipes_in_category correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "recipeCategory": "Desserts"},
|
||||
{"id": 2, "name": "Recipe 2", "recipeCategory": "Desserts"},
|
||||
]
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe in category: {unique_category}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_in_category = await client.get_recipes_in_category("Desserts")
|
||||
|
||||
# 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) == 2
|
||||
assert recipes_in_category[0]["recipeCategory"] == "Desserts"
|
||||
|
||||
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)
|
||||
# Verify URL encoding happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/category/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_list_keywords(nc_client: NextcloudClient):
|
||||
"""Test listing recipe keywords."""
|
||||
logger.info("Listing recipe keywords")
|
||||
keywords = await nc_client.cookbook.list_keywords()
|
||||
async def test_cookbook_list_keywords(mocker):
|
||||
"""Test that list_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"name": "vegetarian", "recipe_count": 15},
|
||||
{"name": "quick", "recipe_count": 8},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
keywords = await client.list_keywords()
|
||||
|
||||
assert isinstance(keywords, list)
|
||||
logger.info(f"Found {len(keywords)} keywords")
|
||||
assert len(keywords) == 2
|
||||
assert keywords[0]["name"] == "vegetarian"
|
||||
assert keywords[0]["recipe_count"] == 15
|
||||
|
||||
# Each keyword should have name and recipe_count
|
||||
if keywords:
|
||||
assert "name" in keywords[0]
|
||||
assert "recipe_count" in keywords[0]
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/cookbook/api/v1/keywords")
|
||||
|
||||
|
||||
async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
||||
"""Test getting recipes with specific keywords.
|
||||
async def test_cookbook_get_recipes_with_keywords(mocker):
|
||||
"""Test that get_recipes_with_keywords correctly parses the API response."""
|
||||
mock_response = create_mock_recipe_list_response(
|
||||
recipes=[
|
||||
{"id": 1, "name": "Recipe 1", "keywords": "vegetarian,quick"},
|
||||
{"id": 2, "name": "Recipe 2", "keywords": "vegetarian,healthy"},
|
||||
]
|
||||
)
|
||||
|
||||
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"],
|
||||
}
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
logger.info(f"Creating recipe with keyword: {unique_keyword}")
|
||||
recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
recipes_with_keywords = await client.get_recipes_with_keywords(
|
||||
["vegetarian", "quick"]
|
||||
)
|
||||
|
||||
try:
|
||||
# Allow extra time for indexing
|
||||
await asyncio.sleep(3)
|
||||
assert isinstance(recipes_with_keywords, list)
|
||||
assert len(recipes_with_keywords) == 2
|
||||
|
||||
# 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)
|
||||
# Verify URL encoding and keyword joining happened
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args[0]
|
||||
assert "/apps/cookbook/api/v1/tags/" in call_args[1]
|
||||
|
||||
|
||||
async def test_cookbook_reindex(nc_client: NextcloudClient):
|
||||
"""Test triggering a reindex of recipes."""
|
||||
logger.info("Triggering recipe reindex")
|
||||
result = await nc_client.cookbook.reindex()
|
||||
async def test_cookbook_reindex(mocker):
|
||||
"""Test that reindex correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data="Reindex completed successfully",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
CookbookClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = CookbookClient(mock_client, "testuser")
|
||||
result = await client.reindex()
|
||||
|
||||
# Should return a success message
|
||||
assert isinstance(result, str)
|
||||
logger.info(f"Reindex result: {result}")
|
||||
assert "reindex" in result.lower() or "completed" in result.lower()
|
||||
|
||||
mock_make_request.assert_called_once_with("POST", "/apps/cookbook/api/v1/reindex")
|
||||
|
||||
+455
-271
@@ -1,327 +1,511 @@
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.models.deck import DeckCard, DeckLabel, DeckStack
|
||||
from nextcloud_mcp_server.client.deck import DeckClient
|
||||
from nextcloud_mcp_server.models.deck import (
|
||||
DeckBoard,
|
||||
DeckCard,
|
||||
DeckComment,
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
)
|
||||
from tests.client.conftest import (
|
||||
create_mock_deck_board_response,
|
||||
create_mock_deck_card_response,
|
||||
create_mock_deck_comment_response,
|
||||
create_mock_deck_label_response,
|
||||
create_mock_deck_stack_response,
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Board CRUD Tests
|
||||
# Board Tests
|
||||
|
||||
|
||||
async def test_deck_board_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete board CRUD workflow using the temporary_board fixture.
|
||||
"""
|
||||
board_data = temporary_board
|
||||
board_id = board_data["id"]
|
||||
original_title = board_data["title"]
|
||||
original_color = board_data["color"]
|
||||
|
||||
logger.info(f"Testing CRUD operations on board ID: {board_id}")
|
||||
|
||||
# Read the board
|
||||
read_board = await nc_client.deck.get_board(board_id)
|
||||
assert read_board.id == board_id
|
||||
assert read_board.title == original_title
|
||||
assert read_board.color == original_color
|
||||
logger.info(f"Successfully read board ID: {board_id}")
|
||||
|
||||
# Update the board
|
||||
updated_title = f"Updated {original_title}"
|
||||
updated_color = "00FF00" # Green color
|
||||
await nc_client.deck.update_board(
|
||||
board_id, title=updated_title, color=updated_color
|
||||
async def test_deck_get_boards(mocker):
|
||||
"""Test that get_boards correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Board 1",
|
||||
"color": "FF0000",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Board 2",
|
||||
"color": "00FF00",
|
||||
"owner": {
|
||||
"primaryKey": "testuser",
|
||||
"uid": "testuser",
|
||||
"displayname": "Test User",
|
||||
},
|
||||
"archived": False,
|
||||
"labels": [],
|
||||
"acl": [],
|
||||
"permissions": {
|
||||
"PERMISSION_READ": True,
|
||||
"PERMISSION_EDIT": True,
|
||||
"PERMISSION_MANAGE": True,
|
||||
"PERMISSION_SHARE": True,
|
||||
},
|
||||
"users": [],
|
||||
"deletedAt": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the update
|
||||
updated_board = await nc_client.deck.get_board(board_id)
|
||||
assert updated_board.title == updated_title
|
||||
assert updated_board.color == updated_color
|
||||
logger.info(f"Successfully updated board ID: {board_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
boards = await client.get_boards()
|
||||
|
||||
async def test_deck_list_boards(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all boards with different options.
|
||||
"""
|
||||
# Test basic listing
|
||||
boards = await nc_client.deck.get_boards()
|
||||
assert isinstance(boards, list)
|
||||
logger.info(f"Found {len(boards)} boards")
|
||||
assert len(boards) == 2
|
||||
assert all(isinstance(b, DeckBoard) for b in boards)
|
||||
assert boards[0].id == 1
|
||||
assert boards[0].title == "Board 1"
|
||||
|
||||
# Test with details
|
||||
detailed_boards = await nc_client.deck.get_boards(details=True)
|
||||
assert isinstance(detailed_boards, list)
|
||||
logger.info(f"Found {len(detailed_boards)} boards with details")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_board_operations_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test operations on non-existent board return appropriate errors.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
# Test get non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.get_board(non_existent_id)
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
] # 403 might be returned for access denied
|
||||
logger.info(
|
||||
f"Get non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
async def test_deck_create_board(mocker):
|
||||
"""Test that create_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="New Board", color="FF0000"
|
||||
)
|
||||
|
||||
# Test update non-existent board
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.deck.update_board(non_existent_id, title="Should Fail")
|
||||
assert excinfo.value.response.status_code in [
|
||||
404,
|
||||
403,
|
||||
400,
|
||||
] # 400 for bad request on invalid board ID
|
||||
logger.info(
|
||||
f"Update non-existent board correctly failed with {excinfo.value.response.status_code}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.create_board(title="New Board", color="FF0000")
|
||||
|
||||
# Stack CRUD Tests
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "New Board"
|
||||
assert board.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "POST"
|
||||
assert call_args[1]["json"]["title"] == "New Board"
|
||||
|
||||
|
||||
async def test_deck_stack_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete stack CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
stack_title = f"Test Stack {uuid.uuid4().hex[:8]}"
|
||||
stack_order = 1
|
||||
stack = None
|
||||
async def test_deck_get_board(mocker):
|
||||
"""Test that get_board correctly parses the API response."""
|
||||
mock_response = create_mock_deck_board_response(
|
||||
board_id=123, title="Test Board", color="0000FF"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create stack
|
||||
stack = await nc_client.deck.create_stack(board_id, stack_title, stack_order)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.title == stack_title
|
||||
assert stack.order == stack_order
|
||||
stack_id = stack.id
|
||||
logger.info(f"Created stack ID: {stack_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read stack
|
||||
read_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert read_stack.id == stack_id
|
||||
assert read_stack.title == stack_title
|
||||
logger.info(f"Successfully read stack ID: {stack_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
board = await client.get_board(board_id=123)
|
||||
|
||||
# Update stack
|
||||
updated_title = f"Updated {stack_title}"
|
||||
updated_order = 2
|
||||
await nc_client.deck.update_stack(
|
||||
board_id, stack_id, title=updated_title, order=updated_order
|
||||
)
|
||||
assert isinstance(board, DeckBoard)
|
||||
assert board.id == 123
|
||||
assert board.title == "Test Board"
|
||||
|
||||
# Verify update
|
||||
updated_stack = await nc_client.deck.get_stack(board_id, stack_id)
|
||||
assert updated_stack.title == updated_title
|
||||
assert updated_stack.order == updated_order
|
||||
logger.info(f"Successfully updated stack ID: {stack_id}")
|
||||
|
||||
# List stacks
|
||||
stacks = await nc_client.deck.get_stacks(board_id)
|
||||
assert isinstance(stacks, list)
|
||||
assert any(s.id == stack_id for s in stacks)
|
||||
logger.info(f"Found stack ID: {stack_id} in board stacks list")
|
||||
|
||||
finally:
|
||||
# Clean up - delete stack
|
||||
if stack and hasattr(stack, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_stack(board_id, stack.id)
|
||||
logger.info(f"Cleaned up stack ID: {stack.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up stack ID: {stack.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Card CRUD Tests
|
||||
async def test_deck_update_board(mocker):
|
||||
"""Test that update_board makes the correct API call."""
|
||||
mock_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_board(board_id=123, title="Updated Board", color="00FF00")
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/boards/123" in call_args[0][1]
|
||||
assert call_args[1]["json"]["title"] == "Updated Board"
|
||||
|
||||
|
||||
async def test_deck_card_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_stack: tuple
|
||||
):
|
||||
"""
|
||||
Test complete card CRUD workflow.
|
||||
"""
|
||||
board_data, stack_data = temporary_board_with_stack
|
||||
board_id = board_data["id"]
|
||||
stack_id = stack_data["id"]
|
||||
async def test_deck_get_board_nonexistent(mocker):
|
||||
"""Test that getting a non-existent board raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Board not found")
|
||||
|
||||
card_title = f"Test Card {uuid.uuid4().hex[:8]}"
|
||||
card_description = f"Test description for card {uuid.uuid4().hex[:8]}"
|
||||
card = None
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
try:
|
||||
# Create card
|
||||
card = await nc_client.deck.create_card(
|
||||
board_id, stack_id, card_title, description=card_description
|
||||
)
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.title == card_title
|
||||
assert card.description == card_description
|
||||
card_id = card.id
|
||||
logger.info(f"Created card ID: {card_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
|
||||
# Read card
|
||||
read_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert read_card.id == card_id
|
||||
assert read_card.title == card_title
|
||||
logger.info(f"Successfully read card ID: {card_id}")
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_board(board_id=999999999)
|
||||
|
||||
# Update card
|
||||
updated_title = f"Updated {card_title}"
|
||||
updated_description = f"Updated description for {card_title}"
|
||||
await nc_client.deck.update_card(
|
||||
board_id,
|
||||
stack_id,
|
||||
card_id,
|
||||
title=updated_title,
|
||||
description=updated_description,
|
||||
)
|
||||
|
||||
# Verify update
|
||||
updated_card = await nc_client.deck.get_card(board_id, stack_id, card_id)
|
||||
assert updated_card.title == updated_title
|
||||
assert updated_card.description == updated_description
|
||||
logger.info(f"Successfully updated card ID: {card_id}")
|
||||
|
||||
# Archive and unarchive card
|
||||
await nc_client.deck.archive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Archived card ID: {card_id}")
|
||||
|
||||
await nc_client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||
logger.info(f"Unarchived card ID: {card_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete card
|
||||
if card and hasattr(card, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_card(board_id, stack_id, card.id)
|
||||
logger.info(f"Cleaned up card ID: {card.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up card ID: {card.id}: {e}")
|
||||
assert excinfo.value.response.status_code == 404
|
||||
|
||||
|
||||
# Label CRUD Tests
|
||||
# Stack Tests
|
||||
|
||||
|
||||
async def test_deck_label_crud_workflow(
|
||||
nc_client: NextcloudClient, temporary_board: dict
|
||||
):
|
||||
"""
|
||||
Test complete label CRUD workflow.
|
||||
"""
|
||||
board_id = temporary_board["id"]
|
||||
label_title = f"Test Label {uuid.uuid4().hex[:8]}"
|
||||
label_color = "FF0000" # Red
|
||||
label = None
|
||||
async def test_deck_create_stack(mocker):
|
||||
"""Test that create_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
try:
|
||||
# Create label
|
||||
label = await nc_client.deck.create_label(board_id, label_title, label_color)
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.title == label_title
|
||||
assert label.color == label_color
|
||||
label_id = label.id
|
||||
logger.info(f"Created label ID: {label_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Read label
|
||||
read_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert read_label.id == label_id
|
||||
assert read_label.title == label_title
|
||||
logger.info(f"Successfully read label ID: {label_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.create_stack(board_id=123, title="Test Stack", order=1)
|
||||
|
||||
# Update label
|
||||
updated_title = f"Updated {label_title}"
|
||||
updated_color = "00FF00" # Green
|
||||
await nc_client.deck.update_label(
|
||||
board_id, label_id, title=updated_title, color=updated_color
|
||||
)
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
assert stack.boardId == 123
|
||||
|
||||
# Verify update
|
||||
updated_label = await nc_client.deck.get_label(board_id, label_id)
|
||||
assert updated_label.title == updated_title
|
||||
assert updated_label.color == updated_color
|
||||
logger.info(f"Successfully updated label ID: {label_id}")
|
||||
|
||||
finally:
|
||||
# Clean up - delete label
|
||||
if label and hasattr(label, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_label(board_id, label.id)
|
||||
logger.info(f"Cleaned up label ID: {label.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up label ID: {label.id}: {e}")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Configuration and Comments Tests
|
||||
async def test_deck_get_stack(mocker):
|
||||
"""Test that get_stack correctly parses the API response."""
|
||||
mock_response = create_mock_deck_stack_response(
|
||||
stack_id=456, title="Test Stack", board_id=123, order=1
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stack = await client.get_stack(board_id=123, stack_id=456)
|
||||
|
||||
assert isinstance(stack, DeckStack)
|
||||
assert stack.id == 456
|
||||
assert stack.title == "Test Stack"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_config_operations(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test deck configuration operations.
|
||||
"""
|
||||
# Get config
|
||||
config = await nc_client.deck.get_config()
|
||||
assert config is not None
|
||||
logger.info(f"Retrieved deck config: {config}")
|
||||
async def test_deck_get_stacks(mocker):
|
||||
"""Test that get_stacks correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{"id": 1, "title": "Stack 1", "boardId": 123, "order": 1, "deletedAt": 0},
|
||||
{"id": 2, "title": "Stack 2", "boardId": 123, "order": 2, "deletedAt": 0},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
stacks = await client.get_stacks(board_id=123)
|
||||
|
||||
assert isinstance(stacks, list)
|
||||
assert len(stacks) == 2
|
||||
assert all(isinstance(s, DeckStack) for s in stacks)
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_comments_workflow(
|
||||
nc_client: NextcloudClient, temporary_board_with_card: tuple
|
||||
):
|
||||
"""
|
||||
Test comment operations on a card.
|
||||
"""
|
||||
board_data, stack_data, card_data = temporary_board_with_card
|
||||
card_id = card_data["id"]
|
||||
# Card Tests
|
||||
|
||||
comment_message = f"Test comment {uuid.uuid4().hex[:8]}"
|
||||
comment = None
|
||||
|
||||
try:
|
||||
# Create comment
|
||||
comment = await nc_client.deck.create_comment(card_id, comment_message)
|
||||
assert comment.message == comment_message
|
||||
comment_id = comment.id
|
||||
logger.info(f"Created comment ID: {comment_id}")
|
||||
async def test_deck_create_card(mocker):
|
||||
"""Test that create_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456, description="Test description"
|
||||
)
|
||||
|
||||
# List comments
|
||||
comments = await nc_client.deck.get_comments(card_id)
|
||||
assert isinstance(comments, list)
|
||||
assert any(c.id == comment_id for c in comments)
|
||||
logger.info(f"Found comment ID: {comment_id} in card comments")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Update comment
|
||||
updated_message = f"Updated {comment_message}"
|
||||
updated_comment = await nc_client.deck.update_comment(
|
||||
card_id, comment_id, updated_message
|
||||
)
|
||||
assert updated_comment.message == updated_message
|
||||
logger.info(f"Successfully updated comment ID: {comment_id}")
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.create_card(
|
||||
board_id=123, stack_id=456, title="Test Card", description="Test description"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Clean up - delete comment
|
||||
if comment and hasattr(comment, "id"):
|
||||
try:
|
||||
await nc_client.deck.delete_comment(card_id, comment.id)
|
||||
logger.info(f"Cleaned up comment ID: {comment.id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up comment ID: {comment.id}: {e}")
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
assert card.description == "Test description"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_card(mocker):
|
||||
"""Test that get_card correctly parses the API response."""
|
||||
mock_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Test Card", stack_id=456
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
card = await client.get_card(board_id=123, stack_id=456, card_id=789)
|
||||
|
||||
assert isinstance(card, DeckCard)
|
||||
assert card.id == 789
|
||||
assert card.title == "Test Card"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/stacks/456/cards/789" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_deck_update_card(mocker):
|
||||
"""Test that update_card makes the correct API calls."""
|
||||
# Mock get_card response (update_card calls get_card first)
|
||||
get_response = create_mock_deck_card_response(
|
||||
card_id=789, title="Original Card", stack_id=456
|
||||
)
|
||||
|
||||
# Mock update response
|
||||
update_response = create_mock_response(status_code=200, json_data={})
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(DeckClient, "_make_request")
|
||||
# First call returns the card, second call is the update
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
await client.update_card(
|
||||
board_id=123, stack_id=456, card_id=789, title="Updated Card"
|
||||
)
|
||||
|
||||
# Should be called twice: GET then PUT
|
||||
assert mock_make_request.call_count == 2
|
||||
|
||||
# Check the PUT call
|
||||
put_call = mock_make_request.call_args_list[1]
|
||||
assert put_call[0][0] == "PUT"
|
||||
assert "/boards/123/stacks/456/cards/789" in put_call[0][1]
|
||||
assert put_call[1]["json"]["title"] == "Updated Card"
|
||||
|
||||
|
||||
# Label Tests
|
||||
|
||||
|
||||
async def test_deck_create_label(mocker):
|
||||
"""Test that create_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.create_label(board_id=123, title="Test Label", color="FF0000")
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
assert label.color == "FF0000"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_label(mocker):
|
||||
"""Test that get_label correctly parses the API response."""
|
||||
mock_response = create_mock_deck_label_response(
|
||||
label_id=111, title="Test Label", color="FF0000", board_id=123
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
label = await client.get_label(board_id=123, label_id=111)
|
||||
|
||||
assert isinstance(label, DeckLabel)
|
||||
assert label.id == 111
|
||||
assert label.title == "Test Label"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/boards/123/labels/111" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
# Comment Tests
|
||||
|
||||
|
||||
async def test_deck_create_comment(mocker):
|
||||
"""Test that create_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Test comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.create_comment(card_id=789, message="Test comment")
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Test comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_get_comments(mocker):
|
||||
"""Test that get_comments correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"objectId": 789,
|
||||
"message": "Comment 1",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"objectId": 789,
|
||||
"message": "Comment 2",
|
||||
"actorId": "testuser",
|
||||
"actorDisplayName": "Test User",
|
||||
"actorType": "users",
|
||||
"creationDateTime": "2024-01-01T00:00:00+00:00",
|
||||
"mentions": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comments = await client.get_comments(card_id=789)
|
||||
|
||||
assert isinstance(comments, list)
|
||||
assert len(comments) == 2
|
||||
assert all(isinstance(c, DeckComment) for c in comments)
|
||||
assert comments[0].message == "Comment 1"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_deck_update_comment(mocker):
|
||||
"""Test that update_comment correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_deck_comment_response(
|
||||
comment_id=222, message="Updated comment", card_id=789
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
comment = await client.update_comment(
|
||||
card_id=789, comment_id=222, message="Updated comment"
|
||||
)
|
||||
|
||||
assert isinstance(comment, DeckComment)
|
||||
assert comment.id == 222
|
||||
assert comment.message == "Updated comment"
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
# Config Test
|
||||
|
||||
|
||||
async def test_deck_get_config(mocker):
|
||||
"""Test that get_config correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data={
|
||||
"ocs": {
|
||||
"meta": {"status": "ok"},
|
||||
"data": {
|
||||
"calendar": True,
|
||||
"cardDetailsInModal": True,
|
||||
"cardIdBadge": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
DeckClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = DeckClient(mock_client, "testuser")
|
||||
config = await client.get_config()
|
||||
|
||||
assert config.calendar is True
|
||||
assert config.cardDetailsInModal is True
|
||||
assert config.cardIdBadge is False
|
||||
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
@@ -1,260 +1,255 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid # Keep uuid if needed for generating unique data within tests
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
# Note: nc_client fixture is now session-scoped in conftest.py
|
||||
from nextcloud_mcp_server.client.notes import NotesClient
|
||||
from tests.client.conftest import create_mock_error_response, create_mock_note_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
async def test_notes_api_create_and_read(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests creating a note via the API (using fixture) and then reading it back.
|
||||
"""
|
||||
created_note_data = temporary_note # Get data from fixture
|
||||
note_id = created_note_data["id"]
|
||||
|
||||
logger.info(f"Reading note created by fixture, ID: {note_id}")
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
|
||||
assert read_note["id"] == note_id
|
||||
assert read_note["title"] == created_note_data["title"]
|
||||
assert read_note["content"] == created_note_data["content"]
|
||||
assert read_note["category"] == created_note_data["category"]
|
||||
logger.info(f"Successfully read and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict):
|
||||
"""
|
||||
Tests updating a note created by the fixture.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
original_category = created_note_data["category"]
|
||||
|
||||
update_title = f"Updated Title {uuid.uuid4().hex[:8]}"
|
||||
update_content = f"Updated Content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Attempting to update note ID: {note_id} with etag: {original_etag}")
|
||||
updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=update_title,
|
||||
content=update_content,
|
||||
# category=original_category # Explicitly pass category if required by update
|
||||
async def test_notes_api_get_note(mocker):
|
||||
"""Test that get_note correctly parses the API response."""
|
||||
# Create mock response
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Test Note",
|
||||
content="Test content",
|
||||
category="Test",
|
||||
etag="abc123",
|
||||
)
|
||||
logger.info(f"Note updated: {updated_note}")
|
||||
|
||||
assert updated_note["id"] == note_id
|
||||
assert updated_note["title"] == update_title
|
||||
assert updated_note["content"] == update_content
|
||||
assert (
|
||||
updated_note["category"] == original_category
|
||||
) # Verify category didn't change
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != original_etag # Etag must change
|
||||
|
||||
# Optional: Verify update by reading again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_updated_note["title"] == update_title
|
||||
assert read_updated_note["content"] == update_content
|
||||
logger.info(f"Successfully updated and verified note ID: {note_id}")
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests that attempting to update with an old etag fails with 412.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_etag = created_note_data["etag"]
|
||||
|
||||
# Perform a first update to change the etag
|
||||
first_update_title = f"First Update {uuid.uuid4().hex[:8]}"
|
||||
logger.info(f"Performing first update on note ID: {note_id} to change etag.")
|
||||
first_updated_note = await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag,
|
||||
title=first_update_title,
|
||||
content="First update content",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
# Mock the _make_request method
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
new_etag = first_updated_note["etag"]
|
||||
assert new_etag != original_etag
|
||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Now attempt update with the *original* etag
|
||||
logger.info(
|
||||
f"Attempting second update on note ID: {note_id} with OLD etag: {original_etag}"
|
||||
# Create client and test
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.get_note(note_id=123)
|
||||
|
||||
# Verify the response was parsed correctly
|
||||
assert note["id"] == 123
|
||||
assert note["title"] == "Test Note"
|
||||
assert note["content"] == "Test content"
|
||||
assert note["category"] == "Test"
|
||||
assert note["etag"] == "abc123"
|
||||
|
||||
# Verify the correct API endpoint was called
|
||||
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
|
||||
|
||||
|
||||
async def test_notes_api_create_note(mocker):
|
||||
"""Test that create_note correctly parses the API response."""
|
||||
mock_response = create_mock_note_response(
|
||||
note_id=456,
|
||||
title="New Note",
|
||||
content="New content",
|
||||
category="Category",
|
||||
etag="def456",
|
||||
)
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.update(
|
||||
note_id=note_id,
|
||||
etag=original_etag, # Use the stale etag
|
||||
title="This update should fail due to conflict",
|
||||
# category=created_note_data["category"] # Pass category if required
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
NotesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
note = await client.create_note(
|
||||
title="New Note", content="New content", category="Category"
|
||||
)
|
||||
|
||||
assert note["id"] == 456
|
||||
assert note["title"] == "New Note"
|
||||
assert note["content"] == "New content"
|
||||
assert note["category"] == "Category"
|
||||
|
||||
# Verify the correct API call was made
|
||||
mock_make_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/apps/notes/api/v1/notes",
|
||||
json={"title": "New Note", "content": "New content", "category": "Category"},
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_update(mocker):
|
||||
"""Test that update correctly parses the API response and handles etag."""
|
||||
# Mock the update response (no category passed, so no GET call happens)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
category="Test",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
# Mock _make_request to return the update response
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.return_value = update_response
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.update(
|
||||
note_id=123,
|
||||
etag="abc123",
|
||||
title="Updated Title",
|
||||
content="Updated content",
|
||||
)
|
||||
|
||||
assert updated_note["id"] == 123
|
||||
assert updated_note["title"] == "Updated Title"
|
||||
assert updated_note["content"] == "Updated content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
# Verify the PUT request was made with the correct etag header (only 1 call since no category)
|
||||
assert mock_make_request.call_count == 1
|
||||
put_call = mock_make_request.call_args_list[0]
|
||||
assert put_call[0] == ("PUT", "/apps/notes/api/v1/notes/123")
|
||||
assert put_call[1]["headers"]["If-Match"] == '"abc123"'
|
||||
|
||||
|
||||
async def test_notes_api_update_conflict(mocker):
|
||||
"""Test that update raises HTTPStatusError on 412 conflict."""
|
||||
# Mock the 412 error response
|
||||
error_response = create_mock_error_response(412, "Precondition Failed")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"412 Precondition Failed",
|
||||
request=httpx.Request("PUT", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.update(
|
||||
note_id=123,
|
||||
etag="old_etag",
|
||||
title="This should fail",
|
||||
)
|
||||
assert excinfo.value.response.status_code == 412 # Precondition Failed
|
||||
logger.info("Update with old etag correctly failed with 412 Precondition Failed.")
|
||||
|
||||
assert excinfo.value.response.status_code == 412
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests deleting a note that doesn't exist fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
logger.info(f"\nAttempting to delete non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.delete_note(note_id=non_existent_id)
|
||||
async def test_notes_api_delete_note(mocker):
|
||||
"""Test that delete_note makes the correct API call."""
|
||||
# Mock get_note response (to fetch category for cleanup)
|
||||
get_response = create_mock_note_response(note_id=123, category="Test")
|
||||
|
||||
# Mock delete response
|
||||
delete_response = create_mock_note_response(note_id=123)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = [get_response, delete_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
await client.delete_note(note_id=123)
|
||||
|
||||
# Verify DELETE was called
|
||||
assert any(call[0][0] == "DELETE" for call in mock_make_request.call_args_list)
|
||||
|
||||
|
||||
async def test_notes_api_delete_nonexistent(mocker):
|
||||
"""Test that deleting a non-existent note raises 404."""
|
||||
# Mock 404 error when fetching note details
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_note(note_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Deleting non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_notes_api_append_content(mocker):
|
||||
"""Test that append_content correctly appends to existing content."""
|
||||
# Mock get_note response (to fetch current content)
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content",
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_existing_note(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content to an existing note using the new append functionality.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
append_text = f"Appended content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
# Mock update response with appended content
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="Original content\n---\nAppended content",
|
||||
etag="new_etag",
|
||||
)
|
||||
logger.info(f"Note after append: {updated_note}")
|
||||
|
||||
# Verify the note was updated
|
||||
assert updated_note["id"] == note_id
|
||||
assert "etag" in updated_note
|
||||
assert updated_note["etag"] != created_note_data["etag"] # Etag must change
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
# Verify content has the separator and appended text
|
||||
expected_content = original_content + "\n---\n" + append_text
|
||||
assert updated_note["content"] == expected_content
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="Appended content")
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content
|
||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||
assert updated_note["content"] == "Original content\n---\nAppended content"
|
||||
assert updated_note["etag"] == "new_etag"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests appending content to an empty note (no separator should be added).
|
||||
"""
|
||||
# Create an empty note
|
||||
test_title = f"Empty Note {uuid.uuid4().hex[:8]}"
|
||||
test_category = "Test"
|
||||
|
||||
logger.info("Creating empty note for append test")
|
||||
empty_note = await nc_client.notes.create_note(
|
||||
title=test_title,
|
||||
async def test_notes_api_append_content_to_empty_note(mocker):
|
||||
"""Test that appending to empty note doesn't add separator."""
|
||||
# Mock get_note response with empty content
|
||||
get_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="",
|
||||
category=test_category, # Empty content
|
||||
)
|
||||
note_id = empty_note["id"]
|
||||
|
||||
try:
|
||||
append_text = f"First content {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Appending content to empty note ID: {note_id}")
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=append_text
|
||||
)
|
||||
|
||||
# For empty notes, content should just be the appended text (no separator)
|
||||
assert updated_note["content"] == append_text
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == append_text
|
||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the test note
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Cleaned up test note ID: {note_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up test note ID: {note_id}: {e}")
|
||||
|
||||
|
||||
async def test_notes_api_append_content_multiple_times(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
):
|
||||
"""
|
||||
Tests appending content multiple times to verify separator behavior.
|
||||
"""
|
||||
created_note_data = temporary_note
|
||||
note_id = created_note_data["id"]
|
||||
original_content = created_note_data["content"]
|
||||
|
||||
first_append = f"First append {uuid.uuid4().hex[:8]}"
|
||||
second_append = f"Second append {uuid.uuid4().hex[:8]}"
|
||||
|
||||
logger.info(f"Performing multiple appends to note ID: {note_id}")
|
||||
|
||||
# First append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=first_append
|
||||
etag="old_etag",
|
||||
)
|
||||
|
||||
expected_content_after_first = original_content + "\n---\n" + first_append
|
||||
assert updated_note["content"] == expected_content_after_first
|
||||
|
||||
# Second append
|
||||
updated_note = await nc_client.notes.append_content(
|
||||
note_id=note_id, content=second_append
|
||||
# Mock update response with just the appended text (no separator)
|
||||
update_response = create_mock_note_response(
|
||||
note_id=123,
|
||||
content="First content",
|
||||
etag="new_etag",
|
||||
)
|
||||
|
||||
expected_content_after_second = (
|
||||
expected_content_after_first + "\n---\n" + second_append
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
# First call: GET (from get_note), second call: PUT (from update)
|
||||
mock_make_request.side_effect = [get_response, update_response]
|
||||
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
updated_note = await client.append_content(note_id=123, content="First content")
|
||||
|
||||
# For empty notes, no separator should be added
|
||||
assert updated_note["content"] == "First content"
|
||||
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(mocker):
|
||||
"""Test that appending to a non-existent note raises 404."""
|
||||
error_response = create_mock_error_response(404, "Not Found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(NotesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
assert updated_note["content"] == expected_content_after_second
|
||||
|
||||
# Verify by reading the note again
|
||||
await asyncio.sleep(1)
|
||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||
assert read_note["content"] == expected_content_after_second
|
||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||
client = NotesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.append_content(note_id=999999999, content="This should fail")
|
||||
|
||||
async def test_notes_api_append_content_nonexistent_note(nc_client: NextcloudClient):
|
||||
"""
|
||||
Tests that appending to a non-existent note fails with 404.
|
||||
"""
|
||||
non_existent_id = 999999999
|
||||
|
||||
logger.info(f"Attempting to append to non-existent note ID: {non_existent_id}")
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.notes.append_content(
|
||||
note_id=non_existent_id, content="This should fail"
|
||||
)
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Appending to non-existent note ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
@@ -1,535 +1,326 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.tables import TablesClient
|
||||
from tests.client.conftest import (
|
||||
create_mock_error_response,
|
||||
create_mock_response,
|
||||
create_mock_table_row_ocs_response,
|
||||
create_mock_table_row_response,
|
||||
create_mock_table_schema_response,
|
||||
create_mock_tables_list_response,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mark all tests in this module as integration tests
|
||||
pytestmark = pytest.mark.integration
|
||||
# Mark all tests in this module as unit tests
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def sample_table_info(nc_client: NextcloudClient) -> Dict[str, Any]:
|
||||
"""
|
||||
Fixture to get information about the sample table that comes with Nextcloud Tables.
|
||||
This assumes that the sample table exists in the Nextcloud instance.
|
||||
"""
|
||||
logger.info("Looking for sample table in Nextcloud Tables app")
|
||||
async def test_tables_list_tables(mocker):
|
||||
"""Test that list_tables correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_tables_list_response(
|
||||
tables=[
|
||||
{"id": 1, "title": "Table 1"},
|
||||
{"id": 2, "title": "Table 2"},
|
||||
]
|
||||
)
|
||||
|
||||
# Get all tables
|
||||
tables = await nc_client.tables.list_tables()
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Look for a sample table (usually created by default)
|
||||
sample_table = None
|
||||
for table in tables:
|
||||
# Common names for sample tables
|
||||
if any(
|
||||
keyword in table.get("title", "").lower()
|
||||
for keyword in ["sample", "demo", "example", "test"]
|
||||
):
|
||||
sample_table = table
|
||||
break
|
||||
|
||||
if not sample_table and tables:
|
||||
# If no sample table found, use the first available table
|
||||
sample_table = tables[0]
|
||||
logger.info(
|
||||
f"No sample table found, using first available table: {sample_table.get('title')}"
|
||||
)
|
||||
|
||||
if not sample_table:
|
||||
pytest.skip(
|
||||
"No tables found in Nextcloud Tables app. Please ensure Tables app is installed and has at least one table."
|
||||
)
|
||||
|
||||
# Get the schema for the sample table
|
||||
table_id = sample_table["id"]
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
|
||||
logger.info(f"Using sample table: {sample_table.get('title')} (ID: {table_id})")
|
||||
|
||||
return {
|
||||
"table": sample_table,
|
||||
"schema": schema,
|
||||
"table_id": table_id,
|
||||
"columns": schema.get("columns", []),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_table_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Fixture to create a temporary row in the sample table for testing.
|
||||
Yields the created row data and cleans up afterward.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 42
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating temporary row in table {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row.get("id")
|
||||
|
||||
if not row_id:
|
||||
pytest.fail("Failed to get ID from created temporary row.")
|
||||
|
||||
logger.info(f"Temporary row created with ID: {row_id}")
|
||||
yield created_row
|
||||
|
||||
finally:
|
||||
if created_row and created_row.get("id"):
|
||||
row_id = created_row["id"]
|
||||
logger.info(f"Cleaning up temporary row ID: {row_id}")
|
||||
try:
|
||||
await nc_client.tables.delete_row(row_id)
|
||||
logger.info(f"Successfully deleted temporary row ID: {row_id}")
|
||||
except HTTPStatusError as e:
|
||||
# Ignore 404 if row was already deleted by the test itself
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting temporary row {row_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Temporary row {row_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting temporary row {row_id}: {e}")
|
||||
|
||||
|
||||
async def test_tables_list_tables(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test listing all tables available to the user.
|
||||
"""
|
||||
logger.info("Testing list_tables functionality")
|
||||
|
||||
tables = await nc_client.tables.list_tables()
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
tables = await client.list_tables()
|
||||
|
||||
assert isinstance(tables, list)
|
||||
assert len(tables) > 0, "Expected at least one table to be available"
|
||||
assert len(tables) == 2
|
||||
assert tables[0]["id"] == 1
|
||||
assert tables[0]["title"] == "Table 1"
|
||||
|
||||
# Check that each table has required fields
|
||||
for table in tables:
|
||||
assert "id" in table
|
||||
assert "title" in table
|
||||
assert isinstance(table["id"], int)
|
||||
assert isinstance(table["title"], str)
|
||||
|
||||
logger.info(f"Successfully listed {len(tables)} tables")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_get_schema(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test getting the schema/structure of a specific table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_schema(mocker):
|
||||
"""Test that get_table_schema correctly parses the API response."""
|
||||
mock_response = create_mock_table_schema_response(
|
||||
table_id=123,
|
||||
columns=[
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_schema for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
schema = await nc_client.tables.get_table_schema(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
schema = await client.get_table_schema(table_id=123)
|
||||
|
||||
assert isinstance(schema, dict)
|
||||
assert "columns" in schema
|
||||
assert isinstance(schema["columns"], list)
|
||||
assert len(schema["columns"]) > 0, "Expected at least one column in the table"
|
||||
assert len(schema["columns"]) == 3
|
||||
assert schema["columns"][0]["title"] == "Name"
|
||||
|
||||
# Check that each column has required fields
|
||||
for column in schema["columns"]:
|
||||
assert "id" in column
|
||||
assert "title" in column
|
||||
assert "type" in column
|
||||
assert isinstance(column["id"], int)
|
||||
assert isinstance(column["title"], str)
|
||||
assert isinstance(column["type"], str)
|
||||
|
||||
logger.info(f"Successfully retrieved schema with {len(schema['columns'])} columns")
|
||||
mock_make_request.assert_called_once()
|
||||
assert "/tables/123/scheme" in mock_make_request.call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_read_table(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test reading rows from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
async def test_tables_get_rows(mocker):
|
||||
"""Test that get_table_rows correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John"},
|
||||
{"columnId": 2, "value": 30},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane"},
|
||||
{"columnId": 2, "value": 25},
|
||||
],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(f"Testing get_table_rows for table ID: {table_id}")
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
# Test without pagination
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123)
|
||||
|
||||
assert isinstance(rows, list)
|
||||
# Note: The table might be empty, so we don't assert len > 0
|
||||
assert len(rows) == 2
|
||||
assert rows[0]["id"] == 1
|
||||
assert rows[0]["tableId"] == 123
|
||||
|
||||
# Test with pagination
|
||||
rows_limited = await nc_client.tables.get_table_rows(table_id, limit=5, offset=0)
|
||||
|
||||
assert isinstance(rows_limited, list)
|
||||
assert len(rows_limited) <= 5
|
||||
|
||||
# If there are rows, check their structure
|
||||
if rows:
|
||||
row = rows[0]
|
||||
assert "id" in row
|
||||
assert "tableId" in row
|
||||
assert "data" in row
|
||||
assert isinstance(row["id"], int)
|
||||
assert isinstance(row["tableId"], int)
|
||||
assert isinstance(row["data"], list)
|
||||
|
||||
logger.info(f"Successfully read {len(rows)} rows from table")
|
||||
mock_make_request.assert_called_once()
|
||||
|
||||
|
||||
async def test_tables_create_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test creating a new row in a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_get_rows_with_pagination(mocker):
|
||||
"""Test that get_table_rows correctly handles pagination parameters."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200,
|
||||
json_data=[
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"data": [],
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# Create test data based on the table schema
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
rows = await client.get_table_rows(table_id=123, limit=5, offset=10)
|
||||
|
||||
# Generate test data based on column type
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 123
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-01-01T12:00:00Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Test Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
test_data[column_id] = f"Test Create {column_title} {unique_suffix}"
|
||||
assert isinstance(rows, list)
|
||||
|
||||
logger.info(f"Testing create_row for table ID: {table_id} with data: {test_data}")
|
||||
|
||||
created_row = None
|
||||
try:
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
|
||||
assert isinstance(created_row, dict)
|
||||
assert "id" in created_row
|
||||
assert "tableId" in created_row
|
||||
assert isinstance(created_row["id"], int)
|
||||
assert created_row["tableId"] == table_id
|
||||
|
||||
# Verify the row was created by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
created_row_id = created_row["id"]
|
||||
|
||||
# Find the created row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == created_row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, (
|
||||
f"Created row with ID {created_row_id} not found in table"
|
||||
)
|
||||
|
||||
logger.info(f"Successfully created row with ID: {created_row_id}")
|
||||
|
||||
finally:
|
||||
# Clean up the created row
|
||||
if created_row and created_row.get("id"):
|
||||
try:
|
||||
await nc_client.tables.delete_row(created_row["id"])
|
||||
logger.info(f"Cleaned up created row ID: {created_row['id']}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up created row: {e}")
|
||||
# Verify pagination parameters were passed
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["params"]["limit"] == 5
|
||||
assert call_args[1]["params"]["offset"] == 10
|
||||
|
||||
|
||||
async def test_tables_update_row(
|
||||
nc_client: NextcloudClient,
|
||||
temporary_table_row: Dict[str, Any],
|
||||
sample_table_info: Dict[str, Any],
|
||||
):
|
||||
"""
|
||||
Test updating an existing row in a table.
|
||||
"""
|
||||
row_id = temporary_table_row["id"]
|
||||
columns = sample_table_info["columns"]
|
||||
async def test_tables_create_row(mocker):
|
||||
"""Test that create_row correctly parses the API response (OCS format)."""
|
||||
mock_response = create_mock_table_row_ocs_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Test Name"},
|
||||
{"columnId": 2, "value": 99},
|
||||
],
|
||||
)
|
||||
|
||||
# Create updated data
|
||||
update_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
test_data = {1: "Test Name", 2: 99}
|
||||
created_row = await client.create_row(table_id=123, data=test_data)
|
||||
|
||||
# Generate updated test data based on column type
|
||||
if column_type == "text":
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
update_data[column_id] = 456
|
||||
elif column_type == "datetime":
|
||||
update_data[column_id] = "2024-12-31T23:59:59Z"
|
||||
elif column_type == "select":
|
||||
# For select columns, use the first option if available
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
update_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
update_data[column_id] = "Updated Option"
|
||||
else:
|
||||
# Default to text for unknown types
|
||||
update_data[column_id] = f"Updated {column_title} {unique_suffix}"
|
||||
assert isinstance(created_row, dict)
|
||||
assert created_row["id"] == 456
|
||||
assert created_row["tableId"] == 123
|
||||
|
||||
logger.info(f"Testing update_row for row ID: {row_id} with data: {update_data}")
|
||||
# Verify the data was transformed to string keys
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[1]["json"]["data"]["1"] == "Test Name"
|
||||
assert call_args[1]["json"]["data"]["2"] == 99
|
||||
|
||||
updated_row = await nc_client.tables.update_row(row_id, update_data)
|
||||
|
||||
async def test_tables_update_row(mocker):
|
||||
"""Test that update_row correctly parses the API response."""
|
||||
mock_response = create_mock_table_row_response(
|
||||
row_id=456,
|
||||
table_id=123,
|
||||
data=[
|
||||
{"columnId": 1, "value": "Updated Name"},
|
||||
{"columnId": 2, "value": 100},
|
||||
],
|
||||
)
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
update_data = {1: "Updated Name", 2: 100}
|
||||
updated_row = await client.update_row(row_id=456, data=update_data)
|
||||
|
||||
assert isinstance(updated_row, dict)
|
||||
assert "id" in updated_row
|
||||
assert updated_row["id"] == row_id
|
||||
assert updated_row["id"] == 456
|
||||
|
||||
# Verify the row was updated by reading it back
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
table_id = sample_table_info["table_id"]
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Find the updated row in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is not None, f"Updated row with ID {row_id} not found in table"
|
||||
|
||||
logger.info(f"Successfully updated row with ID: {row_id}")
|
||||
# Verify the PUT request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_row(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test deleting a row from a table.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
# First create a row to delete
|
||||
test_data = {}
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
|
||||
for column in columns:
|
||||
column_id = column["id"]
|
||||
column_type = column.get("type", "text")
|
||||
column_title = column.get("title", f"column_{column_id}")
|
||||
|
||||
if column_type == "text":
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
elif column_type == "number":
|
||||
test_data[column_id] = 789
|
||||
elif column_type == "datetime":
|
||||
test_data[column_id] = "2024-06-15T10:30:00Z"
|
||||
elif column_type == "select":
|
||||
options = column.get("selectOptions", [])
|
||||
if options:
|
||||
test_data[column_id] = options[0].get("label", "Option 1")
|
||||
else:
|
||||
test_data[column_id] = "Delete Option"
|
||||
else:
|
||||
test_data[column_id] = f"Test Delete {column_title} {unique_suffix}"
|
||||
|
||||
logger.info(f"Creating row for delete test in table ID: {table_id}")
|
||||
|
||||
created_row = await nc_client.tables.create_row(table_id, test_data)
|
||||
row_id = created_row["id"]
|
||||
|
||||
logger.info(f"Testing delete_row for row ID: {row_id}")
|
||||
|
||||
# Delete the row
|
||||
delete_result = await nc_client.tables.delete_row(row_id)
|
||||
|
||||
assert isinstance(delete_result, dict)
|
||||
# The delete response might vary, but it should be successful
|
||||
|
||||
# Verify the row was deleted by trying to find it
|
||||
await asyncio.sleep(1) # Allow potential propagation delay
|
||||
rows = await nc_client.tables.get_table_rows(table_id)
|
||||
|
||||
# Ensure the deleted row is not in the results
|
||||
found_row = None
|
||||
for row in rows:
|
||||
if row["id"] == row_id:
|
||||
found_row = row
|
||||
break
|
||||
|
||||
assert found_row is None, f"Deleted row with ID {row_id} still found in table"
|
||||
|
||||
logger.info(f"Successfully deleted row with ID: {row_id}")
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that deleting a non-existent row fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing delete_row for non-existent row ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.delete_row(non_existent_id)
|
||||
|
||||
# Accept both 404 and 500 as valid error responses for non-existent rows
|
||||
# The API behavior may vary between Nextcloud versions
|
||||
assert excinfo.value.response.status_code in [404, 500]
|
||||
logger.info(
|
||||
f"Deleting non-existent row ID: {non_existent_id} correctly failed with {excinfo.value.response.status_code}."
|
||||
async def test_tables_delete_row(mocker):
|
||||
"""Test that delete_row correctly parses the API response."""
|
||||
mock_response = create_mock_response(
|
||||
status_code=200, json_data={"message": "Row deleted"}
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_transform_row_data(
|
||||
nc_client: NextcloudClient, sample_table_info: Dict[str, Any]
|
||||
):
|
||||
"""
|
||||
Test the transform_row_data utility method.
|
||||
"""
|
||||
table_id = sample_table_info["table_id"]
|
||||
columns = sample_table_info["columns"]
|
||||
|
||||
logger.info(f"Testing transform_row_data for table ID: {table_id}")
|
||||
|
||||
# Get some rows to transform
|
||||
rows = await nc_client.tables.get_table_rows(table_id, limit=5)
|
||||
|
||||
if not rows:
|
||||
logger.info("No rows to transform, skipping transform_row_data test")
|
||||
return
|
||||
|
||||
# Transform the rows
|
||||
transformed_rows = nc_client.tables.transform_row_data(rows, columns)
|
||||
|
||||
assert isinstance(transformed_rows, list)
|
||||
assert len(transformed_rows) == len(rows)
|
||||
|
||||
# Check the structure of transformed rows
|
||||
for i, transformed_row in enumerate(transformed_rows):
|
||||
original_row = rows[i]
|
||||
|
||||
assert "id" in transformed_row
|
||||
assert "tableId" in transformed_row
|
||||
assert "data" in transformed_row
|
||||
assert transformed_row["id"] == original_row["id"]
|
||||
assert transformed_row["tableId"] == original_row["tableId"]
|
||||
assert isinstance(transformed_row["data"], dict)
|
||||
|
||||
# Check that column IDs were transformed to column names
|
||||
for column in columns:
|
||||
column_title = column["title"]
|
||||
# The transformed data should have column names as keys
|
||||
# (though the column might not have data in this row)
|
||||
if any(item["columnId"] == column["id"] for item in original_row["data"]):
|
||||
assert column_title in transformed_row["data"]
|
||||
|
||||
logger.info(f"Successfully transformed {len(transformed_rows)} rows")
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_table_schema(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that getting schema for a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(
|
||||
f"Testing get_table_schema for non-existent table ID: {non_existent_id}"
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(
|
||||
TablesClient, "_make_request", return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_schema(non_existent_id)
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
result = await client.delete_row(row_id=456)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
|
||||
# Verify the DELETE request was made
|
||||
call_args = mock_make_request.call_args
|
||||
assert call_args[0][0] == "DELETE"
|
||||
assert "/rows/456" in call_args[0][1]
|
||||
|
||||
|
||||
async def test_tables_delete_nonexistent_row(mocker):
|
||||
"""Test that deleting a non-existent row raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Row not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("DELETE", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.delete_row(row_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Getting schema for non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
|
||||
|
||||
async def test_tables_get_nonexistent_schema(mocker):
|
||||
"""Test that getting schema for non-existent table raises HTTPStatusError."""
|
||||
error_response = create_mock_error_response(404, "Table not found")
|
||||
|
||||
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_make_request = mocker.patch.object(TablesClient, "_make_request")
|
||||
mock_make_request.side_effect = httpx.HTTPStatusError(
|
||||
"404 Not Found",
|
||||
request=httpx.Request("GET", "http://test.local"),
|
||||
response=error_response,
|
||||
)
|
||||
|
||||
client = TablesClient(mock_client, "testuser")
|
||||
|
||||
async def test_tables_read_nonexistent_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that reading from a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
|
||||
logger.info(f"Testing get_table_rows for non-existent table ID: {non_existent_id}")
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.get_table_rows(non_existent_id)
|
||||
with pytest.raises(httpx.HTTPStatusError) as excinfo:
|
||||
await client.get_table_schema(table_id=999999999)
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Reading from non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
|
||||
|
||||
async def test_tables_create_row_invalid_table(nc_client: NextcloudClient):
|
||||
"""
|
||||
Test that creating a row in a non-existent table fails appropriately.
|
||||
"""
|
||||
non_existent_id = 999999999 # Use an ID highly unlikely to exist
|
||||
test_data = {1: "test value"}
|
||||
def test_tables_transform_row_data():
|
||||
"""Test the transform_row_data utility method (synchronous)."""
|
||||
# This is a pure function, no mocking needed
|
||||
client = TablesClient(None, "testuser") # Client not used for this method
|
||||
|
||||
logger.info(f"Testing create_row for non-existent table ID: {non_existent_id}")
|
||||
raw_rows = [
|
||||
{
|
||||
"id": 1,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "John Doe"},
|
||||
{"columnId": 2, "value": 30},
|
||||
{"columnId": 3, "value": "john@example.com"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"tableId": 123,
|
||||
"createdBy": "testuser",
|
||||
"createdAt": "2024-01-01T00:00:00+00:00",
|
||||
"lastEditBy": "testuser",
|
||||
"lastEditAt": "2024-01-01T00:00:00+00:00",
|
||||
"data": [
|
||||
{"columnId": 1, "value": "Jane Smith"},
|
||||
{"columnId": 2, "value": 25},
|
||||
{"columnId": 3, "value": "jane@example.com"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(HTTPStatusError) as excinfo:
|
||||
await nc_client.tables.create_row(non_existent_id, test_data)
|
||||
columns = [
|
||||
{"id": 1, "title": "Name", "type": "text"},
|
||||
{"id": 2, "title": "Age", "type": "number"},
|
||||
{"id": 3, "title": "Email", "type": "text"},
|
||||
]
|
||||
|
||||
assert excinfo.value.response.status_code == 404
|
||||
logger.info(
|
||||
f"Creating row in non-existent table ID: {non_existent_id} correctly failed with 404."
|
||||
)
|
||||
transformed = client.transform_row_data(raw_rows, columns)
|
||||
|
||||
assert len(transformed) == 2
|
||||
assert transformed[0]["id"] == 1
|
||||
assert transformed[0]["data"]["Name"] == "John Doe"
|
||||
assert transformed[0]["data"]["Age"] == 30
|
||||
assert transformed[0]["data"]["Email"] == "john@example.com"
|
||||
|
||||
assert transformed[1]["data"]["Name"] == "Jane Smith"
|
||||
assert transformed[1]["data"]["Age"] == 25
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -68,7 +67,6 @@ async def test_create_and_delete_share(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_share_permissions(nc_client):
|
||||
"""Test updating share permissions."""
|
||||
# Create a test user to share with
|
||||
@@ -120,7 +118,6 @@ async def test_update_share_permissions(nc_client):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_shares(nc_client):
|
||||
"""Test listing all shares."""
|
||||
# Create a test user to share with
|
||||
|
||||
+1187
-66
File diff suppressed because it is too large
Load Diff
@@ -145,7 +145,7 @@ uv run python -m tests.load.oauth_benchmark -u 2 -d 30 --verbose
|
||||
| `--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://127.0.0.1:8001/mcp` | MCP OAuth server URL |
|
||||
| `--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 |
|
||||
|
||||
@@ -414,7 +414,7 @@ async def show_progress(
|
||||
@click.option(
|
||||
"--url",
|
||||
"-u",
|
||||
default="http://127.0.0.1:8000/mcp",
|
||||
default="http://localhost:8000/mcp",
|
||||
show_default=True,
|
||||
help="MCP server URL",
|
||||
)
|
||||
@@ -463,7 +463,7 @@ def main(
|
||||
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
|
||||
uv run python -m tests.load.benchmark --url http://localhost:8001/mcp
|
||||
"""
|
||||
if verbose:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
@@ -51,7 +51,7 @@ class OAuthCallbackServer:
|
||||
correlation, and stores them in a shared dictionary.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 8081):
|
||||
def __init__(self, host: str = "localhost", port: int = 8081):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.auth_states: dict[str, str] = {}
|
||||
@@ -363,13 +363,13 @@ async def run_oauth_benchmark(
|
||||
try:
|
||||
# Get environment variables
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
callback_url = "http://127.0.0.1:8081/callback"
|
||||
callback_url = "http://localhost:8081/callback"
|
||||
|
||||
# Step 1: Start OAuth callback server
|
||||
print("Step 1/6: Starting OAuth callback server...")
|
||||
callback_server = OAuthCallbackServer(host="127.0.0.1", port=8081)
|
||||
callback_server = OAuthCallbackServer(host="localhost", port=8081)
|
||||
callback_server.start()
|
||||
print("✓ Callback server listening on http://127.0.0.1:8081\n")
|
||||
print("✓ Callback server listening on http://localhost:8081\n")
|
||||
|
||||
# Step 2: Discover OIDC endpoints
|
||||
print("Step 2/6: Discovering OIDC endpoints...")
|
||||
@@ -634,7 +634,7 @@ async def run_oauth_benchmark(
|
||||
)
|
||||
@click.option(
|
||||
"--url",
|
||||
default="http://127.0.0.1:8001/mcp",
|
||||
default="http://localhost:8001/mcp",
|
||||
show_default=True,
|
||||
help="MCP OAuth server URL",
|
||||
)
|
||||
|
||||
@@ -4,11 +4,11 @@ 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 anyio
|
||||
import httpx
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
@@ -138,7 +138,7 @@ class OAuthUserPool:
|
||||
return profile
|
||||
|
||||
async def create_user_session(
|
||||
self, username: str, mcp_url: str = "http://127.0.0.1:8001/mcp"
|
||||
self, username: str, mcp_url: str = "http://localhost:8001/mcp"
|
||||
) -> ClientSession:
|
||||
"""
|
||||
Create an MCP client session for a user.
|
||||
@@ -394,7 +394,7 @@ class OAuthUserPool:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback for {username}"
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Received auth code for {username}")
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -15,6 +14,8 @@ from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
import anyio
|
||||
|
||||
from tests.load.oauth_pool import UserSessionWrapper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -299,7 +300,9 @@ class CollaborativeEditWorkflow(Workflow):
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*read_tasks)
|
||||
async with anyio.create_task_group() as tg:
|
||||
for task in read_tasks:
|
||||
tg.start_soon(task)
|
||||
|
||||
# Step 3: Append content concurrently by all collaborators
|
||||
append_tasks = []
|
||||
@@ -318,7 +321,9 @@ class CollaborativeEditWorkflow(Workflow):
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.gather(*append_tasks)
|
||||
async with anyio.create_task_group() as tg:
|
||||
for task in append_tasks:
|
||||
tg.start_soon(task)
|
||||
|
||||
# Step 4: Owner verifies final state
|
||||
await self._execute_step(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""OAuth-specific integration tests."""
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Test DCR deletion endpoint with different authentication methods.
|
||||
|
||||
This simplified test focuses only on testing the deletion endpoint
|
||||
with various authentication methods to answer the question:
|
||||
"Does the 401 issue occur for both basic auth and credentials in the body?"
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_authentication_methods(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test DCR deletion with different authentication methods.
|
||||
|
||||
Tests:
|
||||
1. HTTP Basic Auth (client_id:client_secret)
|
||||
2. Credentials in JSON body
|
||||
3. Credentials in query parameters
|
||||
|
||||
This answers: Does the 401 issue occur with all authentication methods?
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register a client for testing
|
||||
logger.info("Registering test client...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Auth Methods Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_info.client_id}"
|
||||
logger.info(f"\nTesting deletion endpoint: {deletion_endpoint}")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client Secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
|
||||
results = {}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as test_client:
|
||||
# Method 1: HTTP Basic Auth
|
||||
logger.info("\n=== Method 1: HTTP Basic Auth ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_info.client_id, client_info.client_secret),
|
||||
)
|
||||
results["basic_auth"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["basic_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 2: Credentials in JSON body
|
||||
logger.info("\n=== Method 2: Credentials in JSON Body ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
json={
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
results["json_body"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["json_body"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 3: Credentials in query parameters
|
||||
logger.info("\n=== Method 3: Credentials in Query Parameters ===")
|
||||
try:
|
||||
response = await test_client.delete(
|
||||
deletion_endpoint,
|
||||
params={
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
results["query_params"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["query_params"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Method 4: No authentication (baseline)
|
||||
logger.info("\n=== Method 4: No Authentication (Baseline) ===")
|
||||
try:
|
||||
response = await test_client.delete(deletion_endpoint)
|
||||
results["no_auth"] = {
|
||||
"status": response.status_code,
|
||||
"body": response.text[:200],
|
||||
}
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
except Exception as e:
|
||||
results["no_auth"] = {"status": "error", "error": str(e)}
|
||||
logger.error(f"Error: {e}")
|
||||
|
||||
# Print summary
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("SUMMARY: DCR Deletion Authentication Methods")
|
||||
logger.info("=" * 70)
|
||||
|
||||
for method, result in results.items():
|
||||
status = result.get("status", "unknown")
|
||||
logger.info(f"{method:20s} → Status: {status}")
|
||||
|
||||
# Analysis
|
||||
logger.info("\n" + "=" * 70)
|
||||
logger.info("ANALYSIS")
|
||||
logger.info("=" * 70)
|
||||
|
||||
all_401 = all(
|
||||
r.get("status") == 401 for r in results.values() if r.get("status") != "error"
|
||||
)
|
||||
any_204 = any(r.get("status") == 204 for r in results.values())
|
||||
|
||||
if all_401:
|
||||
logger.info("✗ ALL authentication methods return 401 Unauthorized")
|
||||
logger.info(
|
||||
" This indicates the deletion endpoint does not accept any form of credentials."
|
||||
)
|
||||
logger.info(
|
||||
" Likely cause: RFC 7592 not fully implemented (missing registration_access_token)"
|
||||
)
|
||||
elif any_204:
|
||||
logger.info("✓ At least one authentication method succeeded (204 No Content)")
|
||||
for method, result in results.items():
|
||||
if result.get("status") == 204:
|
||||
logger.info(f" Working method: {method}")
|
||||
else:
|
||||
logger.info("? Mixed results - further investigation needed")
|
||||
for method, result in results.items():
|
||||
logger.info(f" {method}: {result.get('status')}")
|
||||
|
||||
# Document the finding
|
||||
assert all_401 or any_204, (
|
||||
f"Expected either all 401s (not implemented) or at least one 204 (working). "
|
||||
f"Got: {results}"
|
||||
)
|
||||
|
||||
if all_401:
|
||||
logger.info(
|
||||
"\n✓ Test confirms: DCR deletion returns 401 with ALL authentication methods"
|
||||
)
|
||||
else:
|
||||
logger.info("\n✓ Test confirms: DCR deletion works with at least one method")
|
||||
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) lifecycle - register and delete.
|
||||
|
||||
These tests verify the complete lifecycle of DCR clients:
|
||||
1. Registration via RFC 7591
|
||||
2. Token acquisition and use
|
||||
3. Deletion via RFC 7592
|
||||
4. Error handling for deletion edge cases
|
||||
|
||||
This is critical for ensuring the fixture cleanup code works reliably.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import delete_client, register_client
|
||||
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def get_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
token_endpoint: Token endpoint URL
|
||||
authorization_endpoint: Authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([nextcloud_host, username, password]):
|
||||
pytest.skip(
|
||||
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Logging in for DCR lifecycle test...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback
|
||||
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:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_register_and_delete_lifecycle(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test the complete DCR lifecycle: register → use → delete.
|
||||
|
||||
This verifies:
|
||||
1. Client registration succeeds
|
||||
2. Client can obtain tokens and make API calls
|
||||
3. Client deletion succeeds (returns 204)
|
||||
4. Deleted client cannot be used again (tokens are revoked)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Step 1: Register client (and capture full response including registration_access_token)
|
||||
logger.info("Step 1: Registering OAuth client...")
|
||||
|
||||
# Register manually to capture full response
|
||||
client_metadata = {
|
||||
"client_name": "DCR Lifecycle Test Client",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email notes:read",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as reg_client:
|
||||
reg_response = await reg_client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
reg_response.raise_for_status()
|
||||
full_client_info = reg_response.json()
|
||||
|
||||
logger.info(f"Full registration response keys: {list(full_client_info.keys())}")
|
||||
logger.info(f"Registration response: {full_client_info}")
|
||||
|
||||
# Use the register_client function for the ClientInfo object
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Lifecycle Test Client 2",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
# Store RFC 7592 fields if present
|
||||
registration_access_token = full_client_info.get("registration_access_token")
|
||||
registration_client_uri = full_client_info.get("registration_client_uri")
|
||||
logger.info(
|
||||
f"Registration access token present: {registration_access_token is not None}"
|
||||
)
|
||||
logger.info(
|
||||
f"Registration client URI present: {registration_client_uri is not None}"
|
||||
)
|
||||
|
||||
logger.info(f"✅ Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Step 2: Obtain token and verify client works
|
||||
logger.info("Step 2: Obtaining OAuth token with registered client...")
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
scopes="openid profile email notes:read",
|
||||
)
|
||||
|
||||
assert access_token, "Failed to obtain access token"
|
||||
logger.info(f"✅ Access token obtained: {access_token[:30]}...")
|
||||
|
||||
# Step 3: Delete the client using RFC 7592
|
||||
logger.info("Step 3: Deleting OAuth client...")
|
||||
logger.info(f"Client ID: {client_info.client_id}")
|
||||
logger.info(f"Client secret (first 16 chars): {client_info.client_secret[:16]}...")
|
||||
logger.info(
|
||||
f"Registration access token: {registration_access_token[:16] if registration_access_token else 'None'}..."
|
||||
)
|
||||
|
||||
# Use delete_client() which prefers RFC 7592 Bearer token, falls back to Basic Auth
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, (
|
||||
"Client deletion should succeed with RFC 7592 Bearer token or Basic Auth"
|
||||
)
|
||||
logger.info(f"✅ Client deleted successfully: {client_info.client_id[:16]}...")
|
||||
|
||||
# Step 4: Verify deleted client cannot obtain new tokens
|
||||
logger.info("Step 4: Verifying deleted client cannot obtain new tokens...")
|
||||
|
||||
# Try to use the deleted client to get a token
|
||||
# This should fail because the client no longer exists
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
try:
|
||||
# Try to use client credentials grant (should fail)
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# If we get here, check the status code
|
||||
# Accept either 400 (Bad Request) or 401 (Unauthorized) as valid rejection
|
||||
if token_response.status_code in [400, 401]:
|
||||
logger.info(
|
||||
f"✅ Deleted client correctly rejected ({token_response.status_code})"
|
||||
)
|
||||
else:
|
||||
# Unexpected success - client should be deleted
|
||||
pytest.fail(
|
||||
f"Deleted client should not be able to obtain tokens, "
|
||||
f"but got status {token_response.status_code}"
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Expected - client should be rejected
|
||||
if e.response.status_code == 401:
|
||||
logger.info("✅ Deleted client correctly rejected (401 Unauthorized)")
|
||||
else:
|
||||
# Re-raise if it's a different error
|
||||
raise
|
||||
|
||||
logger.info("✅ Complete DCR lifecycle test passed!")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_delete_with_wrong_credentials(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deletion fails with wrong registration_access_token (401 Unauthorized).
|
||||
|
||||
This verifies:
|
||||
1. Client registration succeeds and returns registration_access_token
|
||||
2. Deletion with wrong registration_access_token returns 401
|
||||
3. Deletion with correct registration_access_token succeeds (RFC 7592)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for credential test...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Wrong Credentials Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Try to delete with wrong registration_access_token (RFC 7592 Bearer token)
|
||||
logger.info("Attempting deletion with wrong registration_access_token...")
|
||||
wrong_token = "wrong_token_" + secrets.token_urlsafe(32)
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=wrong_token,
|
||||
client_secret=client_info.client_secret, # Should not be used if token is present
|
||||
)
|
||||
|
||||
assert not success, "Deletion with wrong credentials should fail"
|
||||
logger.info("✅ Deletion correctly failed with wrong credentials")
|
||||
|
||||
# Clean up: Delete with correct RFC 7592 Bearer token
|
||||
logger.info("Cleaning up: deleting with correct registration_access_token...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "Deletion with correct credentials should succeed"
|
||||
logger.info("✅ Cleanup successful with correct credentials")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_delete_nonexistent_client(
|
||||
anyio_backend,
|
||||
):
|
||||
"""
|
||||
Test that deleting a non-existent client fails gracefully.
|
||||
|
||||
This verifies:
|
||||
1. Deletion of fake client_id returns False (not 204)
|
||||
2. No exceptions are raised (graceful failure)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
# Try to delete a client that doesn't exist
|
||||
fake_client_id = "nonexistent_" + secrets.token_urlsafe(16)
|
||||
fake_client_secret = secrets.token_urlsafe(32)
|
||||
|
||||
logger.info(f"Attempting to delete non-existent client: {fake_client_id[:16]}...")
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=fake_client_id,
|
||||
client_secret=fake_client_secret,
|
||||
)
|
||||
|
||||
assert not success, "Deletion of non-existent client should fail"
|
||||
logger.info("✅ Non-existent client deletion correctly failed")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_is_idempotent(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deleting the same client twice fails gracefully on second attempt.
|
||||
|
||||
This verifies:
|
||||
1. First deletion succeeds (204)
|
||||
2. Second deletion fails gracefully (returns False, not an exception)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for idempotency test...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Idempotency Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# First deletion with RFC 7592 Bearer token
|
||||
logger.info("First deletion attempt...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "First deletion should succeed"
|
||||
logger.info("✅ First deletion succeeded")
|
||||
|
||||
# Second deletion (should fail gracefully - token no longer valid after first deletion)
|
||||
logger.info("Second deletion attempt (should fail)...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert not success, "Second deletion should fail (client already deleted)"
|
||||
logger.info("✅ Second deletion correctly failed (client already deleted)")
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Test the new DCR deletion implementation.
|
||||
|
||||
This test verifies that the recently implemented DCR deletion branch works correctly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_new_dcr_registration_includes_access_token(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that registration now includes registration_access_token.
|
||||
|
||||
The new DCR deletion implementation should provide a registration_access_token
|
||||
in the registration response per RFC 7592.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register a client and inspect the full response
|
||||
client_metadata = {
|
||||
"client_name": "DCR New Implementation Test",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
logger.info("Registering client to check for registration_access_token...")
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
registration_data = response.json()
|
||||
|
||||
# Log the full response
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("REGISTRATION RESPONSE")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Response keys: {sorted(registration_data.keys())}")
|
||||
logger.info("\nFull response:")
|
||||
for key, value in sorted(registration_data.items()):
|
||||
if key in ["client_secret", "registration_access_token"]:
|
||||
# Truncate secrets for security
|
||||
logger.info(f" {key}: {value[:20]}... (truncated)")
|
||||
else:
|
||||
logger.info(f" {key}: {value}")
|
||||
|
||||
# Check for RFC 7592 required fields
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("RFC 7592 COMPLIANCE CHECK")
|
||||
logger.info(f"{'=' * 70}")
|
||||
|
||||
has_token = "registration_access_token" in registration_data
|
||||
has_uri = "registration_client_uri" in registration_data
|
||||
|
||||
logger.info(f"registration_access_token present: {has_token}")
|
||||
logger.info(f"registration_client_uri present: {has_uri}")
|
||||
|
||||
if has_token and has_uri:
|
||||
logger.info(
|
||||
"\n✓ PASS: Registration response includes RFC 7592 management fields!"
|
||||
)
|
||||
logger.info(
|
||||
" This means DCR deletion should now work with Bearer token authentication."
|
||||
)
|
||||
|
||||
# Store these for deletion test
|
||||
client_id = registration_data["client_id"]
|
||||
registration_access_token = registration_data["registration_access_token"]
|
||||
registration_client_uri = registration_data.get("registration_client_uri")
|
||||
|
||||
# Now test deletion with the registration_access_token
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("TESTING DCR DELETION WITH REGISTRATION_ACCESS_TOKEN")
|
||||
logger.info(f"{'=' * 70}")
|
||||
|
||||
deletion_endpoint = (
|
||||
registration_client_uri
|
||||
or f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
)
|
||||
logger.info(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Try deletion with Bearer token (RFC 7592 standard)
|
||||
logger.info("\nAttempting deletion with Bearer token...")
|
||||
delete_response = await client.delete(
|
||||
deletion_endpoint,
|
||||
headers={"Authorization": f"Bearer {registration_access_token}"},
|
||||
)
|
||||
|
||||
logger.info(f"Response status: {delete_response.status_code}")
|
||||
logger.info(f"Response body: {delete_response.text[:200]}")
|
||||
|
||||
if delete_response.status_code == 204:
|
||||
logger.info(
|
||||
"\n✓✓✓ SUCCESS! DCR deletion works with new implementation!"
|
||||
)
|
||||
logger.info(" RFC 7592 deletion is now fully functional.")
|
||||
assert True
|
||||
elif delete_response.status_code == 401:
|
||||
logger.error(
|
||||
"\n✗ FAIL: Still getting 401 even with registration_access_token"
|
||||
)
|
||||
logger.error(
|
||||
" The token may not be recognized or there's a middleware issue."
|
||||
)
|
||||
pytest.fail(
|
||||
"DCR deletion failed with 401 even with registration_access_token"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"\n? UNEXPECTED: Got status {delete_response.status_code}"
|
||||
)
|
||||
pytest.fail(
|
||||
f"Unexpected status code: {delete_response.status_code}, body: {delete_response.text[:500]}"
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning(
|
||||
"\n✗ FAIL: Registration response still missing RFC 7592 management fields"
|
||||
)
|
||||
logger.warning(
|
||||
" The new DCR deletion implementation may not be active or needs configuration."
|
||||
)
|
||||
pytest.fail(
|
||||
f"Registration response missing RFC 7592 fields. "
|
||||
f"Has token: {has_token}, Has URI: {has_uri}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_deletion_with_basic_auth_new_impl(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Verify whether HTTP Basic Auth is now supported for deletion.
|
||||
|
||||
Some implementations support both Bearer token and Basic Auth.
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover and register
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
# Register
|
||||
client_metadata = {
|
||||
"client_name": "DCR Basic Auth Test",
|
||||
"redirect_uris": [callback_url],
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
json=client_metadata,
|
||||
)
|
||||
response.raise_for_status()
|
||||
reg_data = response.json()
|
||||
|
||||
client_id = reg_data["client_id"]
|
||||
client_secret = reg_data["client_secret"]
|
||||
deletion_endpoint = f"{nextcloud_host}/apps/oidc/register/{client_id}"
|
||||
|
||||
logger.info(f"\n{'=' * 70}")
|
||||
logger.info("TESTING DCR DELETION WITH HTTP BASIC AUTH")
|
||||
logger.info(f"{'=' * 70}")
|
||||
logger.info(f"Endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.delete(
|
||||
deletion_endpoint,
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
logger.info(f"Status: {response.status_code}")
|
||||
logger.info(f"Body: {response.text[:200]}")
|
||||
|
||||
if response.status_code == 204:
|
||||
logger.info("\n✓ SUCCESS: HTTP Basic Auth works for deletion!")
|
||||
elif response.status_code == 401:
|
||||
logger.info(
|
||||
"\n✗ HTTP Basic Auth not supported - use registration_access_token instead"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"\n? Unexpected status: {response.status_code}")
|
||||
|
||||
# This test is informational - we don't fail if Basic Auth doesn't work
|
||||
# as long as Bearer token works
|
||||
assert True
|
||||
@@ -0,0 +1,395 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) token_type parameter.
|
||||
|
||||
These tests verify that the Nextcloud OIDC server properly honors the token_type
|
||||
parameter during client registration, issuing the correct type of access tokens:
|
||||
- token_type="JWT" → JWT-formatted tokens (RFC 9068)
|
||||
- token_type="Bearer" → Opaque tokens (standard OAuth2)
|
||||
|
||||
This is critical for ensuring:
|
||||
1. Client choice is respected by the OIDC server
|
||||
2. JWT tokens embed scope information in claims
|
||||
3. Opaque tokens require introspection for scope information
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
def is_jwt_format(token: str) -> bool:
|
||||
"""
|
||||
Check if a token is in JWT format (three base64-encoded parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The access token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format, False otherwise
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
|
||||
# Try to decode the header and payload to verify it's valid base64
|
||||
try:
|
||||
# Add padding if needed
|
||||
header_part = parts[0] + "=" * (4 - len(parts[0]) % 4)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
|
||||
# Decode
|
||||
base64.urlsafe_b64decode(header_part)
|
||||
base64.urlsafe_b64decode(payload_part)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""
|
||||
Decode the payload of a JWT token without verification.
|
||||
|
||||
Args:
|
||||
token: The JWT token
|
||||
|
||||
Returns:
|
||||
Dict containing the decoded payload
|
||||
|
||||
Raises:
|
||||
ValueError: If token is not valid JWT format
|
||||
"""
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||
|
||||
# Decode payload (second part)
|
||||
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
payload_bytes = base64.urlsafe_b64decode(payload_part)
|
||||
return json.loads(payload_bytes)
|
||||
|
||||
|
||||
async def get_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID
|
||||
client_secret: OAuth client secret
|
||||
token_endpoint: Token endpoint URL
|
||||
authorization_endpoint: Authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([nextcloud_host, username, password]):
|
||||
pytest.skip(
|
||||
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
|
||||
# Login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Logging in for DCR test...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
await _handle_oauth_consent_screen(page, username)
|
||||
except Exception as e:
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
# Wait for callback
|
||||
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:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_jwt_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=JWT and issues JWT-formatted tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="JWT" succeeds
|
||||
2. Tokens obtained via this client are JWT format (base64.base64.signature)
|
||||
3. JWT payload contains expected claims (sub, iss, scope, etc.)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="JWT"
|
||||
logger.info("Registering OAuth client with token_type=JWT...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - JWT Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="JWT",
|
||||
)
|
||||
|
||||
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is JWT format
|
||||
assert is_jwt_format(access_token), (
|
||||
f"Expected JWT format token (3 parts separated by dots), "
|
||||
f"but got token with {len(access_token.split('.'))} parts"
|
||||
)
|
||||
|
||||
# Decode and verify JWT payload
|
||||
payload = decode_jwt_payload(access_token)
|
||||
|
||||
# Verify standard JWT claims
|
||||
assert "sub" in payload, "JWT payload missing 'sub' claim (subject/user ID)"
|
||||
assert "iss" in payload, "JWT payload missing 'iss' claim (issuer)"
|
||||
assert "exp" in payload, "JWT payload missing 'exp' claim (expiration)"
|
||||
assert "iat" in payload, "JWT payload missing 'iat' claim (issued at)"
|
||||
|
||||
# Verify scope claim exists (critical for MCP tool filtering)
|
||||
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||
scopes = payload["scope"].split()
|
||||
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=JWT works correctly! "
|
||||
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_dcr_respects_bearer_token_type(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that DCR honors token_type=Bearer and issues opaque tokens.
|
||||
|
||||
This verifies:
|
||||
1. Client registration with token_type="Bearer" succeeds
|
||||
2. Tokens obtained via this client are opaque (NOT JWT format)
|
||||
3. Opaque tokens are simple strings, not base64-encoded structures
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Discover OIDC endpoints
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
discovery_response = await client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
# Register client with token_type="Bearer" (opaque tokens)
|
||||
logger.info("Registering OAuth client with token_type=Bearer...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - Bearer Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
logger.info(f"Registered Bearer client: {client_info.client_id[:16]}...")
|
||||
|
||||
# Obtain token via OAuth flow
|
||||
access_token = await get_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
)
|
||||
|
||||
# Verify token is NOT JWT format
|
||||
assert not is_jwt_format(access_token), (
|
||||
f"Expected opaque token (not JWT format), "
|
||||
f"but got token that looks like JWT: {access_token[:50]}..."
|
||||
)
|
||||
|
||||
# Opaque tokens should be simple strings (not parseable as JWT)
|
||||
try:
|
||||
decode_jwt_payload(access_token)
|
||||
pytest.fail("Opaque token should not be decodable as JWT")
|
||||
except ValueError:
|
||||
# Expected - opaque tokens are not JWT format
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=Bearer works correctly! "
|
||||
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""
|
||||
Test that JWT tokens contain scope information in the payload.
|
||||
|
||||
This is critical for MCP server's dynamic tool filtering, which extracts
|
||||
scopes from JWT token claims without making additional API calls.
|
||||
|
||||
Note: Uses existing shared JWT OAuth client fixture.
|
||||
"""
|
||||
from ...conftest import (
|
||||
DEFAULT_FULL_SCOPES,
|
||||
)
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||
|
||||
# This test leverages the existing JWT client creation helper
|
||||
# to verify that JWT tokens contain scope claims
|
||||
|
||||
# The test verifies that when we create a JWT client with specific scopes,
|
||||
# and obtain a token, the token's payload contains those scopes
|
||||
|
||||
# This is already tested implicitly by the scope authorization tests,
|
||||
# but we document the behavior explicitly here for reference
|
||||
|
||||
logger.info(
|
||||
"✅ JWT token scope embedding verified. "
|
||||
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}"
|
||||
)
|
||||
|
||||
# This test primarily serves as documentation
|
||||
# Actual verification happens in test_dcr_respects_jwt_token_type
|
||||
assert True
|
||||
@@ -0,0 +1,479 @@
|
||||
"""
|
||||
Integration tests for token introspection authorization.
|
||||
|
||||
These tests verify that the introspection endpoint properly enforces
|
||||
authorization rules:
|
||||
1. Client authentication is required (401 if missing)
|
||||
2. Only the token owner can introspect its own tokens
|
||||
3. Only the designated resource server can introspect tokens
|
||||
4. Other clients cannot introspect tokens they don't own or aren't the audience for
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
# Import helpers from conftest
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Import from the root tests/ conftest.py using relative import
|
||||
from ...conftest import _handle_oauth_consent_screen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def nextcloud_host() -> str:
|
||||
"""Get Nextcloud host from environment."""
|
||||
host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||
return host
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def oidc_endpoints(nextcloud_host: str) -> dict[str, str]:
|
||||
"""Discover OIDC endpoints."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
|
||||
return {
|
||||
"token_endpoint": config["token_endpoint"],
|
||||
"authorization_endpoint": config.get("authorization_endpoint"),
|
||||
"introspection_endpoint": config.get("introspection_endpoint"),
|
||||
"registration_endpoint": config.get("registration_endpoint"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def test_oauth_clients(
|
||||
nextcloud_host: str, oidc_endpoints: dict[str, str], oauth_callback_server
|
||||
) -> AsyncGenerator[dict[str, tuple[str, str]], None]:
|
||||
"""
|
||||
Create multiple OAuth clients for introspection testing.
|
||||
|
||||
Returns a dict mapping client names to (client_id, client_secret) tuples.
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||
|
||||
clients = {}
|
||||
registration_endpoint = oidc_endpoints["registration_endpoint"]
|
||||
|
||||
# Get the correct callback URL from the oauth_callback_server fixture
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Create client A (will be the token owner)
|
||||
logger.info("Creating OAuth client A for introspection testing")
|
||||
client_a = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client A",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer", # Use opaque tokens for this test
|
||||
)
|
||||
clients["clientA"] = (client_a.client_id, client_a.client_secret)
|
||||
logger.info(f"Created client A: {client_a.client_id[:16]}...")
|
||||
|
||||
# Create client B (will attempt to introspect client A's tokens)
|
||||
logger.info("Creating OAuth client B for introspection testing")
|
||||
client_b = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client B",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientB"] = (client_b.client_id, client_b.client_secret)
|
||||
logger.info(f"Created client B: {client_b.client_id[:16]}...")
|
||||
|
||||
# Create client C (third party, should not be able to introspect)
|
||||
logger.info("Creating OAuth client C for introspection testing")
|
||||
client_c = await register_client(
|
||||
nextcloud_url=nextcloud_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Introspection Test Client C",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type="Bearer",
|
||||
)
|
||||
clients["clientC"] = (client_c.client_id, client_c.client_secret)
|
||||
logger.info(f"Created client C: {client_c.client_id[:16]}...")
|
||||
|
||||
yield clients
|
||||
|
||||
# Cleanup is handled by Nextcloud - clients will be removed when tests are done
|
||||
logger.info("Test OAuth clients fixture complete")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_requires_client_authentication(
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that the introspection endpoint requires client authentication.
|
||||
|
||||
Expected: 401 UNAUTHORIZED when credentials are missing or invalid.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: No credentials
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "some_token"},
|
||||
)
|
||||
assert response.status_code == 401, "Should return 401 without credentials"
|
||||
data = response.json()
|
||||
assert data.get("error") == "invalid_client"
|
||||
|
||||
# Test 2: Invalid credentials
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "some_token"},
|
||||
auth=("invalid_client", "invalid_secret"),
|
||||
)
|
||||
assert response.status_code == 401, "Should return 401 with invalid credentials"
|
||||
data = response.json()
|
||||
logger.info(f"Invalid client response: {data}")
|
||||
# Response may be either {"error": "invalid_client"} or {"message": "..."}
|
||||
# Both are acceptable as long as we get 401
|
||||
assert "error" in data or "message" in data, "Should return error information"
|
||||
|
||||
|
||||
async def _obtain_token_for_client(
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
scope: str = "openid profile email",
|
||||
resource: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain an OAuth token using existing callback server and playwright automation.
|
||||
|
||||
Reuses the pattern from conftest.py's playwright_oauth_token fixture.
|
||||
"""
|
||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||
|
||||
# Get callback server from fixture
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url_parts = [
|
||||
f"{authorization_endpoint}?",
|
||||
"response_type=code&",
|
||||
f"client_id={client_id}&",
|
||||
f"redirect_uri={quote(callback_url, safe='')}&",
|
||||
f"state={state}&",
|
||||
f"scope={quote(scope, safe='')}",
|
||||
]
|
||||
|
||||
if resource:
|
||||
auth_url_parts.append(f"&resource={quote(resource, safe='')}")
|
||||
|
||||
auth_url = "".join(auth_url_parts)
|
||||
|
||||
logger.info(f"Obtaining token for client {client_id[:16]}... with scopes={scope}")
|
||||
if resource:
|
||||
logger.info(f" Resource parameter: {resource[:16]}...")
|
||||
|
||||
# Browser automation (same pattern as conftest.py)
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
logger.debug(f"Navigating to: {auth_url[:100]}...")
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.debug(f"Current URL after navigation: {current_url}")
|
||||
|
||||
# Handle login if needed
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("Login page detected, filling credentials...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.info(f"After login: {current_url}")
|
||||
|
||||
# Wait a bit for page to fully render after login
|
||||
await anyio.sleep(2)
|
||||
current_url = page.url
|
||||
logger.info(f"After waiting, current URL: {current_url}")
|
||||
|
||||
# Check page content for debugging
|
||||
page_content = await page.content()
|
||||
has_consent_div = "#oidc-consent" in page_content
|
||||
logger.info(f"Page has #oidc-consent div: {has_consent_div}")
|
||||
|
||||
# Handle consent screen using the helper from conftest
|
||||
try:
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
logger.info(f"Consent screen handled: {consent_handled}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error handling consent screen: {e}")
|
||||
# Take screenshot for debugging
|
||||
await page.screenshot(path=f"/tmp/consent_error_{state[:8]}.png")
|
||||
logger.error("Consent error screenshot saved")
|
||||
raise
|
||||
|
||||
# Wait for callback server to receive auth code
|
||||
logger.info("Waiting for callback server to receive auth code...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
screenshot_path = (
|
||||
f"/tmp/oauth_introspection_test_timeout_{state[:8]}.png"
|
||||
)
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f"Timeout! Screenshot saved to {screenshot_path}")
|
||||
logger.error(f"Current URL: {page.url}")
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.debug("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token")
|
||||
return access_token
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_client_cannot_introspect_other_clients_tokens(
|
||||
playwright_oauth_token: str,
|
||||
shared_oauth_client_credentials: tuple,
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that one client cannot introspect tokens owned by another client.
|
||||
|
||||
This test uses a pre-authorized shared OAuth client (with existing token)
|
||||
and verifies that a different client cannot introspect that token.
|
||||
|
||||
Expected: introspection returns {active: false} to not reveal token existence.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
# Use the shared OAuth client's token (pre-authorized, working)
|
||||
access_token = playwright_oauth_token
|
||||
shared_client_id, shared_client_secret, _, _, _ = shared_oauth_client_credentials
|
||||
|
||||
# Get a different client to try to introspect
|
||||
different_client_id, different_client_secret = test_oauth_clients["clientB"]
|
||||
|
||||
logger.info(
|
||||
f"Testing introspection with shared client token: {access_token[:16]}..."
|
||||
)
|
||||
logger.info(f"Shared client ID: {shared_client_id[:16]}...")
|
||||
logger.info(f"Different client ID: {different_client_id[:16]}...")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: The owning client (shared client) can introspect its own token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(shared_client_id, shared_client_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Owner client introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Owner client should be able to introspect its own token"
|
||||
)
|
||||
|
||||
# Test 2: A different client CANNOT introspect the shared client's token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(different_client_id, different_client_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Different client introspection response: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Different client should NOT be able to introspect another client's token"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_with_resource_parameter(
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
nextcloud_host: str,
|
||||
):
|
||||
"""
|
||||
Test that the resource server (specified via 'resource' parameter) can introspect tokens.
|
||||
|
||||
This test verifies that when a token is issued with resource=clientB,
|
||||
clientB can introspect it even though it's owned by clientA.
|
||||
|
||||
This requires obtaining a token with the 'resource' parameter set via authorization code grant.
|
||||
|
||||
Uses playwright automation to obtain real tokens.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
client_a_id, client_a_secret = test_oauth_clients["clientA"]
|
||||
client_b_id, client_b_secret = test_oauth_clients["clientB"]
|
||||
client_c_id, client_c_secret = test_oauth_clients["clientC"]
|
||||
|
||||
token_endpoint = oidc_endpoints["token_endpoint"]
|
||||
authorization_endpoint = oidc_endpoints.get("authorization_endpoint")
|
||||
if not authorization_endpoint:
|
||||
pytest.skip("Authorization endpoint not available")
|
||||
|
||||
# Obtain a token for client A with resource parameter set to client B
|
||||
try:
|
||||
access_token = await _obtain_token_for_client(
|
||||
browser=browser,
|
||||
oauth_callback_server=oauth_callback_server,
|
||||
client_id=client_a_id,
|
||||
client_secret=client_a_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
scope="openid profile email",
|
||||
resource=client_b_id, # Set client B as the resource server
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to obtain token with resource parameter: {e}")
|
||||
pytest.skip(f"Cannot obtain test token with resource parameter: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Obtained access token from client A with resource={client_b_id}: {access_token[:16]}..."
|
||||
)
|
||||
|
||||
# Test introspection
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test 1: Client A (owner) can introspect its own token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_a_id, client_a_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client A (owner) introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Client A (owner) should be able to introspect its own token"
|
||||
)
|
||||
|
||||
# Test 2: Client B (resource server) can introspect the token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_b_id, client_b_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client B (resource server) introspection response: {data}")
|
||||
assert data.get("active") is True, (
|
||||
"Client B (resource server) should be able to introspect token intended for it"
|
||||
)
|
||||
|
||||
# Verify the resource field in the response matches client B
|
||||
logger.info(f"Full introspection response from Client B: {data}")
|
||||
|
||||
# Test 3: Client C CANNOT introspect the token (not owner, not resource server)
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": access_token},
|
||||
auth=(client_c_id, client_c_secret),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Client C (third party) introspection response: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Client C should NOT be able to introspect token (not owner or resource server)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_introspection_returns_inactive_for_invalid_token(
|
||||
test_oauth_clients: dict[str, tuple[str, str]],
|
||||
oidc_endpoints: dict[str, str],
|
||||
):
|
||||
"""
|
||||
Test that introspection returns {active: false} for invalid/unknown tokens.
|
||||
|
||||
This is important for security - we shouldn't reveal whether a token exists or not.
|
||||
"""
|
||||
introspection_endpoint = oidc_endpoints["introspection_endpoint"]
|
||||
if not introspection_endpoint:
|
||||
pytest.skip("Introspection endpoint not available")
|
||||
|
||||
client_a_id, client_a_secret = test_oauth_clients["clientA"]
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Test with a fake token
|
||||
response = await client.post(
|
||||
introspection_endpoint,
|
||||
data={"token": "completely_fake_token_12345"},
|
||||
auth=(client_a_id, client_a_secret),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
logger.info(f"Introspection response for fake token: {data}")
|
||||
assert data.get("active") is False, (
|
||||
"Should return active=false for invalid token"
|
||||
)
|
||||
# Should NOT return any other information
|
||||
assert len(data) == 1, "Should only return 'active' field for invalid token"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run with: uv run pytest tests/server/test_introspection_authorization.py -v -s
|
||||
pytest.main([__file__, "-v", "-s", "-m", "integration"])
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Core OAuth integration tests.
|
||||
|
||||
Consolidated from:
|
||||
- test_mcp_oauth.py: Basic OAuth connectivity
|
||||
- test_mcp_oauth_jwt.py: JWT-specific operations
|
||||
- test_jwt_tokens.py: JWT token structure validation
|
||||
|
||||
Tests verify:
|
||||
1. OAuth server connectivity and tool listing
|
||||
2. Tool execution with OAuth tokens
|
||||
3. JWT token structure and claims
|
||||
4. Multiple operations with same token (persistence)
|
||||
5. Error handling with OAuth
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Basic OAuth Connectivity Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
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.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT-Specific Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test that list_tools works with JWT authentication and returns expected tools.
|
||||
|
||||
This test verifies that tools are properly filtered based on per-app scopes:
|
||||
- notes:read/write → Notes app tools
|
||||
- calendar:read/write → Calendar app tools
|
||||
- files:read/write → WebDAV/Files app tools
|
||||
- etc.
|
||||
"""
|
||||
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||
|
||||
# Verify we have tools
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Verify expected tools exist based on configured scopes
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
|
||||
# Notes tools (require notes:read and notes:write)
|
||||
assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)"
|
||||
assert "nc_notes_create_note" in tool_names, (
|
||||
"Missing nc_notes_create_note (notes:write)"
|
||||
)
|
||||
|
||||
# Calendar tools (require calendar:read and calendar:write)
|
||||
assert "nc_calendar_list_calendars" in tool_names, (
|
||||
"Missing nc_calendar_list_calendars (calendar:read)"
|
||||
)
|
||||
assert "nc_calendar_create_event" in tool_names, (
|
||||
"Missing nc_calendar_create_event (calendar:write)"
|
||||
)
|
||||
|
||||
# Verify we have a reasonable number of tools for the configured scopes
|
||||
# With notes + calendar scopes, expect ~20-30 tools
|
||||
assert len(tool_names) >= 20, (
|
||||
f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||
"""Test multiple operations with same JWT token to verify token persistence.
|
||||
|
||||
JWT tokens should work across multiple tool calls without re-authentication,
|
||||
demonstrating that the token is properly cached and reused.
|
||||
"""
|
||||
# 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 3 different operations with same JWT token (token persistence verified)"
|
||||
)
|
||||
|
||||
|
||||
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||
"""Test error handling with JWT authentication.
|
||||
|
||||
Verifies that invalid operations return proper errors even with valid JWT tokens.
|
||||
"""
|
||||
# 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 OAuth server correctly handles errors for invalid operations")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# JWT Token Structure Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||
"""Document that JWT tokens embed scopes in the payload (RFC 9068).
|
||||
|
||||
This test documents expected JWT structure based on manual testing.
|
||||
"""
|
||||
expected_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 (e.g., 'notes:read notes:write')",
|
||||
"client_id": "client identifier",
|
||||
"jti": "JWT ID",
|
||||
},
|
||||
"scope_claim": {
|
||||
"format": "space-separated string",
|
||||
"example": "openid profile email notes:read notes:write",
|
||||
"extraction": "payload['scope'].split()",
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("JWT token structure (RFC 9068):")
|
||||
logger.info(json.dumps(expected_structure, indent=2))
|
||||
|
||||
# This test documents expected behavior
|
||||
assert True
|
||||
|
||||
|
||||
async def test_opaque_token_vs_jwt_comparison():
|
||||
"""Document differences between opaque tokens and JWT tokens.
|
||||
|
||||
This test captures our findings about the two token types.
|
||||
"""
|
||||
findings = {
|
||||
"jwt_advantages": [
|
||||
"Scopes embedded in payload - no introspection needed",
|
||||
"Self-contained - can validate with JWKS",
|
||||
"Standard approach (RFC 9068)",
|
||||
],
|
||||
"jwt_disadvantages": [
|
||||
"10-15x larger than opaque tokens (~800-1200 chars vs 72)",
|
||||
"Cannot be easily revoked (until expiration)",
|
||||
],
|
||||
"token_sizes": {
|
||||
"opaque": "72 characters",
|
||||
"jwt": "~800-1200 characters",
|
||||
},
|
||||
"recommendation": "Use JWT for MCP server (scopes available without introspection)",
|
||||
}
|
||||
|
||||
logger.info("JWT vs Opaque token comparison:")
|
||||
logger.info(json.dumps(findings, indent=2))
|
||||
|
||||
assert True
|
||||
-4
@@ -46,7 +46,6 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
|
||||
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deck_board_view_permissions(
|
||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -119,7 +118,6 @@ async def test_deck_board_view_permissions(
|
||||
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
|
||||
):
|
||||
@@ -214,7 +212,6 @@ async def test_deck_board_edit_permissions(
|
||||
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
|
||||
):
|
||||
@@ -289,7 +286,6 @@ async def test_deck_board_manage_permissions(
|
||||
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.
|
||||
+10
-14
@@ -18,7 +18,6 @@ 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
|
||||
):
|
||||
@@ -104,7 +103,6 @@ async def test_file_share_read_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_share_write_permissions(
|
||||
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||
):
|
||||
@@ -210,7 +208,6 @@ async def test_file_share_write_permissions(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that file listing respects share permissions.
|
||||
@@ -276,9 +273,9 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
|
||||
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]
|
||||
# Extract files from DirectoryListing response
|
||||
files = response_data.get("files", [])
|
||||
file_names = [f["name"] for f in files]
|
||||
logger.info(f"Alice can see files: {file_names}")
|
||||
|
||||
# Alice should see her own files
|
||||
@@ -294,9 +291,9 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
|
||||
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]
|
||||
# Extract files from DirectoryListing response
|
||||
files = response_data.get("files", [])
|
||||
file_names = [f["name"] for f in files]
|
||||
logger.info(f"Bob can see files: {file_names}")
|
||||
|
||||
# Bob should see his own file, but not Alice's private file
|
||||
@@ -326,7 +323,6 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||
"""
|
||||
Test that folder sharing works correctly.
|
||||
@@ -383,12 +379,12 @@ async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||
|
||||
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")
|
||||
# Extract files from DirectoryListing response
|
||||
files = response_data.get("files", [])
|
||||
logger.info(f"Bob can see {len(files)} files in shared folder")
|
||||
|
||||
# Bob should see the file in the shared folder
|
||||
file_names = [f["name"] for f in response_data]
|
||||
file_names = [f["name"] for f in files]
|
||||
assert "document.txt" in file_names, (
|
||||
"Bob should see the file in shared folder"
|
||||
)
|
||||
-4
@@ -15,7 +15,6 @@ 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
|
||||
):
|
||||
@@ -82,7 +81,6 @@ async def test_notes_share_read_permissions(
|
||||
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
|
||||
):
|
||||
@@ -149,7 +147,6 @@ async def test_notes_share_write_permissions(
|
||||
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.
|
||||
@@ -222,7 +219,6 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
|
||||
await nc_client.notes.delete_note(bob_note_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oauth_mcp_clients_initialized(
|
||||
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
||||
):
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
|
||||
|
||||
These tests verify:
|
||||
1. Dynamic tool filtering based on user's token scopes (using JWT tokens)
|
||||
2. Scope enforcement (403 responses for insufficient scopes)
|
||||
3. Protected Resource Metadata (PRM) endpoint (RFC 9728)
|
||||
4. WWW-Authenticate challenge headers
|
||||
5. BasicAuth bypass (all tools visible)
|
||||
|
||||
Note: Tests use JWT OAuth tokens because scopes are embedded in the token payload,
|
||||
enabling efficient scope-based tool filtering without additional API calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_prm_endpoint():
|
||||
"""Test that the Protected Resource Metadata endpoint returns correct data."""
|
||||
import httpx
|
||||
|
||||
# Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"http://localhost:8001/.well-known/oauth-protected-resource/mcp"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
prm_data = response.json()
|
||||
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
||||
assert "notes:read" in prm_data["scopes_supported"]
|
||||
assert "notes: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 read scopes filters out write tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "notes: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 (only for apps with :read scopes)
|
||||
# Read-only token has: notes:read, calendar:read, contacts:read,
|
||||
# cookbook:read, deck:read, tables:read, files:read, sharing:read
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
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 (filtered out)
|
||||
write_tools_should_be_filtered = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
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 write scopes filters out read tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "notes: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
|
||||
# Write-only token has: notes:write, calendar:write, contacts:write,
|
||||
# cookbook:write, deck:write, tables:write, files:write, sharing:write
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
||||
|
||||
# Verify read-only tools are NOT present (write-only scope)
|
||||
read_tools_should_be_filtered = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
]
|
||||
|
||||
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 read and write scopes scopes can see all tools."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has both "notes:read" and "notes: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")
|
||||
logger.info(f"Tools: {sorted(tool_names)}")
|
||||
|
||||
# Verify both read and write tools are present
|
||||
# Full access has all *read and *write scopes
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
]
|
||||
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
]
|
||||
|
||||
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 = ["notes:read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
|
||||
|
||||
# Test get_required_scopes
|
||||
assert get_required_scopes(mock_read_tool) == ["notes:read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["notes:write"]
|
||||
assert get_required_scopes(mock_no_scope_tool) == []
|
||||
|
||||
# Test has_required_scopes
|
||||
read_only_scopes = {"notes:read"}
|
||||
full_scopes = {"notes:read", "notes: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("notes:read", "notes:write")
|
||||
async def test_function():
|
||||
pass
|
||||
|
||||
# Check that metadata was stored
|
||||
assert hasattr(test_function, "_required_scopes")
|
||||
assert test_function._required_scopes == ["notes:read", "notes: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.skip(reason="Script no longer exists - decorators are already in place")
|
||||
@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") == "notes:read"
|
||||
assert classify_function("nc_notes_search_notes") == "notes:read"
|
||||
assert classify_function("nc_calendar_list_events") == "calendar:read"
|
||||
assert classify_function("nc_webdav_read_file") == "files:read"
|
||||
assert classify_function("nc_calendar_find_availability") == "calendar:read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
|
||||
|
||||
# Test write operations
|
||||
assert classify_function("nc_notes_create_note") == "notes:write"
|
||||
assert classify_function("nc_notes_update_note") == "notes:write"
|
||||
assert classify_function("nc_notes_delete_note") == "notes:write"
|
||||
assert classify_function("nc_notes_append_content") == "notes:write"
|
||||
assert classify_function("nc_calendar_create_event") == "calendar:write"
|
||||
assert classify_function("nc_calendar_update_event") == "notes:write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "notes:write"
|
||||
assert classify_function("nc_webdav_write_file") == "files:write"
|
||||
assert classify_function("nc_webdav_move_resource") == "notes:write"
|
||||
assert classify_function("nc_contacts_create_contact") == "notes:write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
|
||||
assert classify_function("nc_tables_insert_row") == "notes:write"
|
||||
assert classify_function("deck_archive_card") == "notes:write"
|
||||
assert classify_function("deck_assign_label_to_card") == "notes:write"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||
@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,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
|
||||
@@ -1,8 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
@@ -136,7 +136,7 @@ async def test_mcp_cookbook_update_recipe(
|
||||
)
|
||||
|
||||
# 4. Verify update via direct NextcloudClient
|
||||
await asyncio.sleep(1) # Allow propagation
|
||||
await anyio.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
|
||||
@@ -282,7 +282,7 @@ async def test_mcp_cookbook_search_recipes(
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. Search for the recipe via MCP
|
||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||
@@ -358,7 +358,7 @@ async def test_mcp_cookbook_categories_workflow(
|
||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||
|
||||
# 2. Allow time for indexing
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. List categories via MCP
|
||||
logger.info("Listing categories via MCP")
|
||||
@@ -433,9 +433,9 @@ async def test_mcp_cookbook_keywords_workflow(
|
||||
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 anyio.sleep(3)
|
||||
await nc_client.cookbook.reindex()
|
||||
await asyncio.sleep(2)
|
||||
await anyio.sleep(2)
|
||||
|
||||
# 3. List keywords via MCP
|
||||
logger.info("Listing keywords via MCP")
|
||||
|
||||
@@ -57,6 +57,11 @@ async def test_mcp_connectivity(nc_mcp_client: ClientSession):
|
||||
"nc_calendar_find_availability",
|
||||
"nc_calendar_bulk_operations",
|
||||
"nc_calendar_manage_calendar",
|
||||
"nc_calendar_list_todos",
|
||||
"nc_calendar_create_todo",
|
||||
"nc_calendar_update_todo",
|
||||
"nc_calendar_delete_todo",
|
||||
"nc_calendar_search_todos",
|
||||
"deck_create_board",
|
||||
"nc_cookbook_import_recipe",
|
||||
"nc_cookbook_list_recipes",
|
||||
@@ -465,16 +470,21 @@ async def test_mcp_webdav_workflow(
|
||||
logger.info(f"Directory listing response: {listing_text}")
|
||||
listing_data = json.loads(listing_text)
|
||||
|
||||
# Ensure listing_data is a list
|
||||
if not isinstance(listing_data, list):
|
||||
logger.warning(
|
||||
f"Expected directory listing to be a list, got: {type(listing_data)}"
|
||||
)
|
||||
listing_data = [listing_data] if listing_data else []
|
||||
# Extract files from DirectoryListing response
|
||||
assert "files" in listing_data, "Expected 'files' field in directory listing"
|
||||
files = listing_data["files"]
|
||||
assert isinstance(files, list), (
|
||||
f"Expected files to be a list, got: {type(files)}"
|
||||
)
|
||||
|
||||
# Verify metadata
|
||||
assert listing_data["path"] == test_dir
|
||||
assert listing_data["total_count"] >= 1
|
||||
assert listing_data["files_count"] >= 1
|
||||
|
||||
# Find our file in the listing
|
||||
found_file = None
|
||||
for item in listing_data:
|
||||
for item in files:
|
||||
if isinstance(item, dict) and item.get("name") == test_file:
|
||||
found_file = item
|
||||
break
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||
"""Test connection to OAuth-enabled MCP server."""
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||
|
||||
|
||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||
import json
|
||||
|
||||
# Example: Execute the 'nc_notes_search_notes' tool
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
|
||||
|
||||
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.call_tool(
|
||||
"nc_notes_search_notes", arguments={"query": ""}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
response_data = json.loads(result.content[0].text)
|
||||
|
||||
# The search response should have a 'results' field containing the list
|
||||
assert "results" in response_data
|
||||
assert isinstance(response_data["results"], list)
|
||||
|
||||
logger.info(
|
||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||
)
|
||||
@@ -1,9 +1,6 @@
|
||||
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
|
||||
@@ -29,7 +26,6 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
"""Test updating user fields."""
|
||||
user_config = test_user
|
||||
@@ -44,7 +40,6 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||
# 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
|
||||
@@ -61,7 +56,6 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||
# 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
|
||||
@@ -82,7 +76,6 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
|
||||
# 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
|
||||
@@ -102,7 +95,6 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Critical path smoke tests for quick validation."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Smoke tests - critical path tests for quick validation.
|
||||
|
||||
These tests verify the most essential functionality:
|
||||
- MCP server connectivity
|
||||
- Basic CRUD operations for core apps
|
||||
- OAuth authentication
|
||||
- Tool schema validation
|
||||
|
||||
Run with: uv run pytest -m smoke -v
|
||||
Expected runtime: ~30-60 seconds
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
|
||||
|
||||
|
||||
async def test_mcp_connectivity_smoke(nc_mcp_client):
|
||||
"""Smoke test: Verify MCP server is reachable and lists tools."""
|
||||
tools = await nc_mcp_client.list_tools()
|
||||
|
||||
# Should have a reasonable number of tools
|
||||
assert len(tools.tools) > 30, f"Expected >30 tools, got {len(tools.tools)}"
|
||||
|
||||
# Check for core tool categories
|
||||
tool_names = [tool.name for tool in tools.tools]
|
||||
assert any("notes" in name for name in tool_names), "Missing notes tools"
|
||||
assert any("calendar" in name for name in tool_names), "Missing calendar tools"
|
||||
assert any("webdav" in name for name in tool_names), "Missing webdav tools"
|
||||
|
||||
|
||||
async def test_notes_crud_smoke(nc_mcp_client, nc_client):
|
||||
"""Smoke test: Verify basic Notes CRUD operations work."""
|
||||
# Create
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_create_note",
|
||||
arguments={
|
||||
"title": "Smoke Test Note",
|
||||
"content": "Testing basic CRUD",
|
||||
"category": "test",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False
|
||||
data = json.loads(create_result.content[0].text)
|
||||
note_id = data["id"]
|
||||
|
||||
try:
|
||||
# Read
|
||||
get_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_get_note",
|
||||
arguments={"note_id": note_id},
|
||||
)
|
||||
assert get_result.isError is False
|
||||
|
||||
# Update
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_update_note",
|
||||
arguments={
|
||||
"note_id": note_id,
|
||||
"title": "Updated Smoke Test",
|
||||
"content": "Updated content",
|
||||
"category": "test",
|
||||
"etag": data["etag"],
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False
|
||||
|
||||
finally:
|
||||
# Delete
|
||||
delete_result = await nc_mcp_client.call_tool(
|
||||
"nc_notes_delete_note",
|
||||
arguments={"note_id": note_id},
|
||||
)
|
||||
assert delete_result.isError is False
|
||||
|
||||
|
||||
async def test_calendar_basic_smoke(nc_mcp_client):
|
||||
"""Smoke test: Verify calendar operations work."""
|
||||
# List calendars
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_list_calendars",
|
||||
arguments={},
|
||||
)
|
||||
assert result.isError is False
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "calendars" in data
|
||||
assert len(data["calendars"]) > 0
|
||||
|
||||
|
||||
async def test_webdav_basic_smoke(nc_mcp_client):
|
||||
"""Smoke test: Verify WebDAV file operations work."""
|
||||
# List root directory
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_webdav_list_directory",
|
||||
arguments={"path": "/"},
|
||||
)
|
||||
assert result.isError is False
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "files" in data
|
||||
assert isinstance(data["files"], list)
|
||||
|
||||
|
||||
@pytest.mark.oauth
|
||||
async def test_oauth_connectivity_smoke(nc_mcp_oauth_client):
|
||||
"""Smoke test: Verify OAuth authentication works."""
|
||||
# List tools with OAuth
|
||||
result = await nc_mcp_oauth_client.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
|
||||
# Execute a simple tool
|
||||
search_result = await nc_mcp_oauth_client.call_tool(
|
||||
"nc_notes_search_notes",
|
||||
arguments={"query": ""},
|
||||
)
|
||||
assert search_result.isError is False
|
||||
@@ -0,0 +1,286 @@
|
||||
"""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 notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
||||
)
|
||||
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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests with mocked dependencies for fast feedback."""
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Unit tests for Pydantic response models."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
CreateNoteResponse,
|
||||
Note,
|
||||
NoteSearchResult,
|
||||
SearchNotesResponse,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_note_model_creation():
|
||||
"""Test creating a Note model with required fields."""
|
||||
note = Note(
|
||||
id=123,
|
||||
title="Test Note",
|
||||
content="# Test Content",
|
||||
modified=1700000000,
|
||||
etag="abc123",
|
||||
)
|
||||
|
||||
assert note.id == 123
|
||||
assert note.title == "Test Note"
|
||||
assert note.content == "# Test Content"
|
||||
assert note.category == "" # default value
|
||||
assert note.favorite is False # default value
|
||||
assert note.etag == "abc123"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_note_modified_datetime_property():
|
||||
"""Test that Note.modified_datetime converts Unix timestamp correctly."""
|
||||
note = Note(
|
||||
id=1,
|
||||
title="Test",
|
||||
content="Content",
|
||||
modified=1700000000,
|
||||
etag="etag",
|
||||
)
|
||||
|
||||
dt = note.modified_datetime
|
||||
assert dt.year == 2023 # Nov 14, 2023
|
||||
assert dt.month == 11
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_note_response_serialization():
|
||||
"""Test CreateNoteResponse can serialize to JSON."""
|
||||
response = CreateNoteResponse(
|
||||
id=42,
|
||||
title="New Note",
|
||||
category="Work",
|
||||
etag="xyz789",
|
||||
)
|
||||
|
||||
# Test serialization
|
||||
data = response.model_dump()
|
||||
assert data["id"] == 42
|
||||
assert data["title"] == "New Note"
|
||||
assert data["category"] == "Work"
|
||||
assert data["etag"] == "xyz789"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_search_notes_response_wraps_results():
|
||||
"""Test SearchNotesResponse wraps list of results correctly.
|
||||
|
||||
This is critical - FastMCP mangles raw List[Dict] responses,
|
||||
so we must wrap them in a response model.
|
||||
"""
|
||||
results = [
|
||||
NoteSearchResult(id=1, title="First Note", category="Work"),
|
||||
NoteSearchResult(id=2, title="Second Note", category="Personal"),
|
||||
]
|
||||
|
||||
response = SearchNotesResponse(
|
||||
results=results,
|
||||
query="test query",
|
||||
total_found=2,
|
||||
)
|
||||
|
||||
# Verify the response structure
|
||||
assert len(response.results) == 2
|
||||
assert response.results[0].id == 1
|
||||
assert response.results[1].title == "Second Note"
|
||||
assert response.query == "test query"
|
||||
assert response.total_found == 2
|
||||
|
||||
# Verify it serializes correctly
|
||||
data = response.model_dump()
|
||||
assert "results" in data
|
||||
assert isinstance(data["results"], list)
|
||||
assert len(data["results"]) == 2
|
||||
assert data["results"][0]["id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_note_search_result_with_score():
|
||||
"""Test NoteSearchResult with optional score field."""
|
||||
result = NoteSearchResult(
|
||||
id=99,
|
||||
title="Relevant Note",
|
||||
category="Archive",
|
||||
score=0.95,
|
||||
)
|
||||
|
||||
assert result.id == 99
|
||||
assert result.score == 0.95
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_note_search_result_without_score():
|
||||
"""Test NoteSearchResult without optional score field."""
|
||||
result = NoteSearchResult(
|
||||
id=99,
|
||||
title="Relevant Note",
|
||||
category="Archive",
|
||||
)
|
||||
|
||||
assert result.id == 99
|
||||
assert result.score is None
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Unit tests for scope decorator metadata and classification logic."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||
InsufficientScopeError,
|
||||
require_scopes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_scope_decorator_stores_metadata():
|
||||
"""Test that @require_scopes decorator stores scope requirements as function metadata."""
|
||||
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
async def example_function():
|
||||
pass
|
||||
|
||||
# Verify metadata is stored
|
||||
assert hasattr(example_function, "_required_scopes")
|
||||
assert example_function._required_scopes == ["notes:read", "notes:write"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_scope_decorator_with_single_scope():
|
||||
"""Test decorator with a single scope requirement."""
|
||||
|
||||
@require_scopes("calendar:read")
|
||||
async def example_function():
|
||||
pass
|
||||
|
||||
assert example_function._required_scopes == ["calendar:read"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_scope_decorator_with_no_scopes():
|
||||
"""Test decorator with no scope requirements."""
|
||||
|
||||
@require_scopes()
|
||||
async def example_function():
|
||||
pass
|
||||
|
||||
assert example_function._required_scopes == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_insufficient_scope_error():
|
||||
"""Test InsufficientScopeError exception structure."""
|
||||
missing = ["notes:write", "calendar:write"]
|
||||
error = InsufficientScopeError(missing)
|
||||
|
||||
assert error.missing_scopes == missing
|
||||
assert "notes:write" in str(error)
|
||||
assert "calendar:write" in str(error)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_insufficient_scope_error_with_custom_message():
|
||||
"""Test InsufficientScopeError with custom message."""
|
||||
missing = ["files:write"]
|
||||
custom_msg = "You need more permissions"
|
||||
error = InsufficientScopeError(missing, custom_msg)
|
||||
|
||||
assert error.missing_scopes == missing
|
||||
assert str(error) == custom_msg
|
||||
+1
Submodule third_party/oidc added at e4659c79ef
-674
@@ -1,674 +0,0 @@
|
||||
=========================
|
||||
Instruction set for users
|
||||
=========================
|
||||
|
||||
Add a new user
|
||||
--------------
|
||||
|
||||
Create a new user on the Nextcloud server. Authentication is done by sending a
|
||||
basic HTTP authentication header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: userid - string, the required username for the new user
|
||||
* POST argument: password - string, the password for the new user, leave empty to send welcome mail
|
||||
* POST argument: displayName - string, the display name for the new user
|
||||
* POST argument: email - string, the email for the new user, required if password empty
|
||||
* POST argument: groups - array, the groups for the new user
|
||||
* POST argument: subadmin - array, the groups in which the new user is subadmin
|
||||
* POST argument: quota - string, quota for the new user
|
||||
* POST argument: language - string, language for the new user
|
||||
|
||||
Status codes:
|
||||
|
||||
* 101 - invalid argument
|
||||
* 102 - user already exists
|
||||
* 103 - cannot create sub-admins for admin group
|
||||
* 104 - group does not exist
|
||||
* 105 - insufficient privileges for group
|
||||
* 106 - no group specified (required for sub-admins)
|
||||
* 107 - hint exceptions
|
||||
* 108 - an email address is required, to send a password link to the user.
|
||||
* 109 - sub-admin group does not exist
|
||||
* 110 - required email address was not provided
|
||||
* 111 - could not create non-existing user ID
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
::
|
||||
|
||||
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users -d userid="Frank" -d password="frankspassword" -H "OCS-APIRequest: true"
|
||||
|
||||
* Creates the user ``Frank`` with password ``frankspassword``
|
||||
* optionally groups can be specified by one or more ``groups[]`` query parameters:
|
||||
``URL -d groups[]="admin" -D groups[]="Team1"``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Search/get users
|
||||
----------------
|
||||
|
||||
Retrieves a list of users from the Nextcloud server. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users**
|
||||
|
||||
* HTTP method: GET
|
||||
* url arguments: search - string, optional search string
|
||||
* url arguments: limit - int, optional limit value
|
||||
* url arguments: offset - int, optional offset value
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users?search=Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns list of users matching the search string.
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<users>
|
||||
<element>Frank</element>
|
||||
</users>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Get data of a single user
|
||||
-------------------------
|
||||
|
||||
Retrieves information about a single user. Authentication is done by sending a
|
||||
Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns information on the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<enabled>true</enabled>
|
||||
<id>Frank</id>
|
||||
<quota>0</quota>
|
||||
<email>frank@example.org</email>
|
||||
<displayname>Frank K.</displayname>
|
||||
<display-name>Frank K.</display-name>
|
||||
<phone>0123 / 456 789</phone>
|
||||
<address>Foobar 12, 12345 Town</address>
|
||||
<website>https://nextcloud.com</website>
|
||||
<twitter>Nextcloud</twitter>
|
||||
<groups>
|
||||
<element>group1</element>
|
||||
<element>group2</element>
|
||||
</groups>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Edit data of a single user
|
||||
--------------------------
|
||||
|
||||
Edits attributes related to a user. Users are able to edit email, displayname
|
||||
and password; admins can also edit the quota value. Further restrictions may apply,
|
||||
check the `List of editable data fields`_ endpoint. Authentication
|
||||
is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: PUT
|
||||
* PUT argument: key, the field to edit:
|
||||
|
||||
+ email
|
||||
+ quota
|
||||
+ displayname
|
||||
+ display (**deprecated** use `displayname` instead)
|
||||
+ phone
|
||||
+ address
|
||||
+ website
|
||||
+ twitter
|
||||
+ password
|
||||
|
||||
* PUT argument: value, the new value for the field
|
||||
|
||||
Status codes:
|
||||
|
||||
* 101 - invalid argument
|
||||
* 107 - password policy (hint exception)
|
||||
* 112 - Setting the password is not supported by the users backend
|
||||
* 113 - editing field not allowed / field doesn’t exist
|
||||
|
||||
Examples
|
||||
^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="email" -d value="franksnewemail@example.org" -H "OCS-APIRequest: true"
|
||||
|
||||
* Updates the email address for the user ``Frank``
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -d key="quota" -d value="100MB" -H "OCS-APIRequest: true"
|
||||
|
||||
* Updates the quota for the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
.. _editable_field_list:
|
||||
|
||||
List of editable data fields
|
||||
----------------------------
|
||||
|
||||
Edits attributes related to a user. Users are able to edit email, displayname
|
||||
and password; admins can also edit the quota value. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/user/fields**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Examples
|
||||
^^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/user/fields -H "OCS-APIRequest: true"
|
||||
|
||||
* Gets the list of fields
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message>OK</message>
|
||||
</meta>
|
||||
<data>
|
||||
<element>displayname</element>
|
||||
<element>email</element>
|
||||
<element>phone</element>
|
||||
<element>address</element>
|
||||
<element>website</element>
|
||||
<element>twitter</element>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
|
||||
Disable a user
|
||||
--------------
|
||||
|
||||
Disables a user on the Nextcloud server so that the user cannot login anymore.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/disable**
|
||||
|
||||
* HTTP method: PUT
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/disable -H "OCS-APIRequest: true"
|
||||
|
||||
* Disables the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Enable a user
|
||||
-------------
|
||||
|
||||
Enables a user on the Nextcloud server so that the user can login again.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/enable**
|
||||
|
||||
* HTTP method: PUT
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X PUT http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/enable -H "OCS-APIRequest: true"
|
||||
|
||||
* Enables the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Delete a user
|
||||
-------------
|
||||
|
||||
Deletes a user from the Nextcloud server. Authentication is done by sending a
|
||||
Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}**
|
||||
|
||||
* HTTP method: DELETE
|
||||
|
||||
Statuscodes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank -H "OCS-APIRequest: true"
|
||||
|
||||
* Deletes the user ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Get user's groups
|
||||
-----------------
|
||||
|
||||
Retrieves a list of groups the specified user is a member of. Authentication is
|
||||
done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -H "OCS-APIRequest: true"
|
||||
|
||||
* Retrieves a list of groups of which ``Frank`` is a member
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data>
|
||||
<groups>
|
||||
<element>admin</element>
|
||||
<element>group1</element>
|
||||
</groups>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Add user to group
|
||||
-----------------
|
||||
|
||||
Adds the specified user to the specified group. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: groupid, string - the group to add the user to
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - no group specified
|
||||
* 102 - group does not exist
|
||||
* 103 - user does not exist
|
||||
* 104 - insufficient privileges
|
||||
* 105 - failed to add user to group
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Adds the user ``Frank`` to the group ``newgroup``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Remove user from group
|
||||
----------------------
|
||||
|
||||
Removes the specified user from the specified group. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/groups**
|
||||
|
||||
* HTTP method: DELETE
|
||||
* DELETE argument: groupid, string - the group to remove the user from
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - no group specified
|
||||
* 102 - group does not exist
|
||||
* 103 - user does not exist
|
||||
* 104 - insufficient privileges
|
||||
* 105 - failed to remove user from group
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE http://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/groups -d groupid="newgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Removes the user ``Frank`` from the group ``newgroup``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Promote user to subadmin
|
||||
------------------------
|
||||
|
||||
Makes a user the subadmin of a group. Authentication is done by sending a Basic
|
||||
HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: POST
|
||||
* POST argument: groupid, string - the group of which to make the user a
|
||||
subadmin
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - group does not exist
|
||||
* 103 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="group" -H "OCS-APIRequest: true"
|
||||
|
||||
* Makes the user ``Frank`` a subadmin of the ``group`` group
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Demote user from subadmin
|
||||
-------------------------
|
||||
|
||||
Removes the subadmin rights for the user specified from the group specified.
|
||||
Authentication is done by sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: DELETE
|
||||
* DELETE argument: groupid, string - the group from which to remove the user's
|
||||
subadmin rights
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - user is not a subadmin of the group / group does not exist
|
||||
* 103 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X DELETE https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -d groupid="oldgroup" -H "OCS-APIRequest: true"
|
||||
|
||||
* Removes ``Frank's`` subadmin rights from the ``oldgroup`` group
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<statuscode>100</statuscode>
|
||||
<status>ok</status>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
|
||||
Get user's subadmin groups
|
||||
--------------------------
|
||||
|
||||
Returns the groups in which the user is a subadmin. Authentication is done by
|
||||
sending a Basic HTTP Authorization header.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/subadmins**
|
||||
|
||||
* HTTP method: GET
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - user does not exist
|
||||
* 102 - unknown failure
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X GET https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/subadmins -H "OCS-APIRequest: true"
|
||||
|
||||
* Returns the groups of which ``Frank`` is a subadmin
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data>
|
||||
<element>testgroup</element>
|
||||
</data>
|
||||
</ocs>
|
||||
|
||||
Resend the welcome email
|
||||
------------------------
|
||||
|
||||
The request to this endpoint triggers the welcome email for this user again.
|
||||
|
||||
**Syntax: ocs/v1.php/cloud/users/{userid}/welcome**
|
||||
|
||||
* HTTP method: POST
|
||||
|
||||
Status codes:
|
||||
|
||||
* 100 - successful
|
||||
* 101 - email address not available
|
||||
* 102 - sending email failed
|
||||
|
||||
Example
|
||||
^^^^^^^
|
||||
|
||||
::
|
||||
|
||||
$ curl -X POST https://admin:secret@example.com/ocs/v1.php/cloud/users/Frank/welcome -H "OCS-APIRequest: true"
|
||||
|
||||
* Sends the welcome email to ``Frank``
|
||||
|
||||
XML output
|
||||
^^^^^^^^^^
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0"?>
|
||||
<ocs>
|
||||
<meta>
|
||||
<status>ok</status>
|
||||
<statuscode>100</statuscode>
|
||||
<message/>
|
||||
</meta>
|
||||
<data/>
|
||||
</ocs>
|
||||
@@ -52,6 +52,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "caldav"
|
||||
version = "2.0.2.dev38+g1aa2be35e"
|
||||
source = { git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx#1aa2be35e94883b44efd42f1cd82d281f8f58e60" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "icalendar" },
|
||||
{ name = "lxml" },
|
||||
{ name = "recurring-ical-events" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
@@ -61,6 +72,76 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
@@ -270,6 +351,68 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "decli"
|
||||
version = "0.6.3"
|
||||
@@ -360,6 +503,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hpack"
|
||||
version = "4.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
@@ -388,6 +553,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
@@ -397,6 +567,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyperframe"
|
||||
version = "6.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icalendar"
|
||||
version = "6.3.1"
|
||||
@@ -513,6 +692,108 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "4.0.0"
|
||||
@@ -613,7 +894,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.18.0"
|
||||
version = "1.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -628,9 +909,9 @@ dependencies = [
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -650,15 +931,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.15.2"
|
||||
version = "0.20.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "caldav" },
|
||||
{ name = "click" },
|
||||
{ name = "httpx" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "pillow" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pythonvcard4" },
|
||||
]
|
||||
|
||||
@@ -669,6 +952,7 @@ dev = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-playwright-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
@@ -676,12 +960,14 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "caldav", git = "https://github.com/cbcoutinho/caldav?branch=feature%2Fhttpx" },
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.19,<1.20" },
|
||||
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
]
|
||||
|
||||
@@ -692,6 +978,7 @@ dev = [
|
||||
{ name = "playwright", specifier = ">=1.49.1" },
|
||||
{ name = "pytest", specifier = ">=8.3.5" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
|
||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||
{ name = "ruff", specifier = ">=0.11.13" },
|
||||
@@ -872,6 +1159,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.3"
|
||||
@@ -1015,6 +1311,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.2"
|
||||
@@ -1071,6 +1381,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright-asyncio"
|
||||
version = "0.7.1"
|
||||
@@ -1236,6 +1558,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recurring-ical-events"
|
||||
version = "3.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "icalendar" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata" },
|
||||
{ name = "x-wr-timezone" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/90/05dfcc02ecf58bd170305c88db9e3e3933aa73a1f8abac2b326c7fdc1a98/recurring_ical_events-3.8.0.tar.gz", hash = "sha256:3e8c7c35d9bd8956a7ab91afad51477c60d972e1236d3fd1b55087a66bce7d04", size = 602665, upload-time = "2025-06-10T13:23:50.662Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/25/88a4218cccae06ce6b15e41d2f263dd4a73e8e8cbe41537cd7784a17479b/recurring_ical_events-3.8.0-py3-none-any.whl", hash = "sha256:cf958eb17c92d4dca5c621e44c2b3fffd4ba700dca0db66287c5dc11438f63ba", size = 238228, upload-time = "2025-06-10T13:23:49.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
@@ -1697,3 +2034,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x-wr-timezone"
|
||||
version = "2.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user