Compare commits

..

25 Commits

Author SHA1 Message Date
Chris Coutinho 1a7ce5b7a7 docs: Update jwt docs [skip ci] 2025-10-23 12:22:34 +02:00
Chris Coutinho 737780b417 chore: Make all env vars available to be overriden as cli options 2025-10-23 11:48:01 +02:00
Chris Coutinho b4039e2e40 docs: Update jwt docs 2025-10-23 11:20:49 +02:00
Chris Coutinho 54e975198f test: Update all test network hosts to respect iss claims from JWTs 2025-10-23 11:09:51 +02:00
Chris Coutinho e9a16c43b5 refactor: Update JWT client to use DCR, re-enable tool filtering 2025-10-23 09:33:06 +02:00
Chris Coutinho e48f5f3f30 feat(server): Add support for custom OIDC scopes and permissions via JWTs 2025-10-23 08:37:36 +02:00
Chris Coutinho 3ebc468a09 ci: Tasks has been updated, no longer a debug app 2025-10-23 07:53:52 +02:00
Chris Coutinho 1aecb099e6 fix: Use occ-created OAuth clients with allowed_scopes for all tests
The shared_oauth_client_credentials fixture was using Dynamic Client
Registration which doesn't support Nextcloud's allowed_scopes parameter.
This caused tokens to lack proper scope configuration, resulting in empty
tool lists when the server validated scopes.

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

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

Fixes test_mcp_oauth_server_connection which was getting empty tool list

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

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

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

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

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

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

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

All were failing with: assert len(result.tools) > 0 (result.tools was empty)
2025-10-22 07:02:40 +02:00
Chris Coutinho 894723c525 ci: Add missing files 2025-10-22 06:40:11 +02:00
Chris Coutinho 8a3269f366 test: Use separate docker compose command 2025-10-22 06:38:05 +02:00
Chris Coutinho c069d78f80 feat: Initialize JWT-scoped tools 2025-10-22 06:21:16 +02:00
Chris Coutinho f4f9548681 Merge pull request #229 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.0
chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f
2025-10-21 13:45:08 +02:00
renovate-bot-cbcoutinho[bot] 27bb0a4b56 chore(deps): update docker.io/library/nextcloud:32.0.0 docker digest to 4fbd72f 2025-10-21 10:06:57 +00:00
Chris Coutinho 7f5828390c docs: Update README 2025-10-21 11:47:01 +02:00
Chris Coutinho 8ad1937347 docs: Update README 2025-10-21 11:26:11 +02:00
Chris Coutinho 0d29048155 Merge pull request #228 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7
2025-10-21 00:10:27 +02:00
Chris Coutinho 499429706c Merge branch 'master' into renovate/astral-sh-setup-uv-7.x 2025-10-21 00:09:50 +02:00
Chris Coutinho 2903094d67 Merge pull request #227 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-10-21 00:09:12 +02:00
renovate-bot-cbcoutinho[bot] 7abfa19d15 chore(deps): update astral-sh/setup-uv action to v7 2025-10-20 22:06:35 +00:00
renovate-bot-cbcoutinho[bot] c109626601 chore(deps): pin dependencies 2025-10-20 22:06:30 +00:00
Chris Coutinho a5a4e809c4 ci: Add smoke test during release 2025-10-20 23:39:47 +02:00
github-actions[bot] 4984496d81 bump: version 0.17.0 → 0.17.1 2025-10-20 21:16:09 +00:00
Chris Coutinho 0e79ba06a9 Merge pull request #226 from cbcoutinho/feature/docs
Feature/docs
2025-10-20 23:15:20 +02:00
46 changed files with 5285 additions and 186 deletions
+6 -2
View File
@@ -18,12 +18,16 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install uv
uses: astral-sh/setup-uv@v6
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
+20 -1
View File
@@ -25,6 +25,25 @@ jobs:
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
submodules: 'true'
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
- name: Run docker compose
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
@@ -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
+6
View File
@@ -0,0 +1,6 @@
[submodule "oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
[submodule "third_party/oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
+6
View File
@@ -1,3 +1,9 @@
## v0.17.1 (2025-10-20)
### Fix
- **caldav**: Fix caldav search() due to missing todos
## v0.17.0 (2025-10-19)
### Feat
+12
View File
@@ -252,3 +252,15 @@ uv run pytest tests/server/test_oauth*.py -v
- **`pyproject.toml`** - Python project configuration using uv for dependency management
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
## Integration testing with docker
### Nextcloud
- The `app` container is running nextcloud.
- Use `docker compose exec app php occ ...` to get a list of available commands
### Mariadb
- The `db` container is running mariadb
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
+3 -1
View File
@@ -7,7 +7,9 @@
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.
> **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.
### High-level Comparison: Nextcloud MCP Server vs. Nextcloud AI Stack
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
@@ -6,7 +6,7 @@ echo "Installing and configuring Calendar app..."
# Enable calendar app
php /var/www/html/occ app:enable calendar
php /var/www/html/occ app:enable --force tasks # Not currently supported on 32
php /var/www/html/occ app:enable tasks
# Wait for calendar app to be fully initialized
echo "Waiting for calendar app to initialize..."
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring OIDC app for testing..."
# Check if development OIDC app is mounted at /opt/apps/oidc
if [ -d /opt/apps/oidc ]; then
echo "Development OIDC app found at /opt/apps/oidc"
# Remove any existing OIDC app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/oidc ]; then
echo "Removing existing OIDC in custom_apps..."
rm -rf /var/www/html/custom_apps/oidc
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc"
ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc
echo "Enabling OIDC app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable oidc
elif [ -d /var/www/html/custom_apps/oidc ]; then
echo "OIDC app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable oidc
else
echo "OIDC app not found, installing from app store..."
php /var/www/html/occ app:install oidc
php /var/www/html/occ app:enable oidc
fi
# Configure OIDC Identity Provider with dynamic client registration enabled
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
echo "OIDC app installed and configured successfully"
@@ -2,21 +2,12 @@
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
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
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
# 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"
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
+31 -4
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.0@sha256:3e70e4dfe882ef44738fdc30d9896fb07c12febb27c4a1177e3d63dc0004a0b4
image: docker.io/library/nextcloud:32.0.0@sha256:4fbd72f05b5e6b82e078542b6cb2ecf021d2f8b5045454ffa7f4e080e488b375
restart: always
ports:
- 0.0.0.0:8080:80
@@ -31,6 +31,9 @@ services:
volumes:
- nextcloud:/var/www/html
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party/oidc:/opt/apps/oidc:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -71,13 +74,37 @@ services:
- 127.0.0.1:8001:8001
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080
# No USERNAME/PASSWORD - will use OAuth
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
volumes:
- oauth-client-storage:/app/.oauth
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
restart: always
depends_on:
- app
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth-jwt/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration (DCR)
# Client will be registered with token_type=JWT on first startup
volumes:
- oauth-jwt-client-storage:/app/.oauth-jwt
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-jwt-client-storage:
+899
View File
@@ -0,0 +1,899 @@
# JWT OAuth Reference - Nextcloud MCP Server
**Last Updated:** 2025-10-23
**Status:** Production Ready
## Table of Contents
- [Overview](#overview)
- [JWT vs Opaque Tokens](#jwt-vs-opaque-tokens)
- [Scope-Based Authorization](#scope-based-authorization)
- [Configuration](#configuration)
- [Architecture](#architecture)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Production Deployment](#production-deployment)
---
## Overview
The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 9068) and **opaque** tokens. JWT tokens are recommended for production use as they enable:
- **Faster validation** - No HTTP call needed for token verification
- **Direct scope extraction** - Scopes embedded in token claims
- **Dynamic tool filtering** - Users only see tools they have permission to use
- **Signature verification** - Cryptographic validation using JWKS
### Key Features
-**JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
-**Custom Scopes** - `nc:read` and `nc:write` for read/write access control
-**Dynamic Tool Filtering** - Tools filtered based on user's token scopes
-**Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
-**Protected Resource Metadata** - RFC 8959 endpoint for scope discovery
-**Backward Compatible** - BasicAuth mode bypasses all scope checks
### Supported Scopes
| Scope | Description | Tool Count |
|-------|-------------|------------|
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
| `nc:write` | Write access to create/modify/delete data | 54 tools |
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
---
## JWT vs Opaque Tokens
The Nextcloud OIDC app supports two token formats, configured per-client:
### JWT Tokens (Recommended)
**Advantages:**
- ✅ Fast validation - JWT signature verified locally using JWKS
- ✅ Direct scope extraction from `scope` claim in payload
- ✅ Standard approach (RFC 9068)
- ✅ No additional HTTP calls for validation
**Disadvantages:**
- ⚠️ Larger size (~800-1200 chars vs 72 chars for opaque)
- ⚠️ Token payload visible to client (not an issue for access tokens)
**Token Structure:**
```json
{
"header": {
"typ": "at+JWT",
"alg": "RS256",
"kid": "..."
},
"payload": {
"iss": "http://localhost:8080",
"sub": "admin",
"aud": "client_id",
"exp": 1234567890,
"iat": 1234567890,
"scope": "openid profile email nc:read nc:write",
"client_id": "...",
"jti": "..."
}
}
```
### Opaque Tokens
**Advantages:**
- ✅ Smaller size (72 characters)
- ✅ No payload visible to client
- ✅ Direct scope access via introspection endpoint (RFC 7662)
**Disadvantages:**
- ❌ Higher latency - Requires HTTP call to introspection endpoint
- ❌ Slower than JWT signature verification (network roundtrip)
**Validation Method:**
Opaque tokens are validated using the **introspection endpoint** (`/apps/oidc/introspect`), which returns:
- Token active status
- Scope claim (direct access, no inference needed)
- User information (`sub`, `username`)
- Token metadata (`exp`, `iat`, `client_id`)
Falls back to userinfo endpoint only if introspection is unavailable.
**When to Use:**
- Use **JWT tokens** for production (better performance, no HTTP call)
- Use **opaque tokens** for compatibility with clients that don't support JWT
---
## Scope-Based Authorization
### Scope Definitions
The MCP server uses **coarse-grained scopes** for simplicity:
| Scope | Operations | Examples |
|-------|------------|----------|
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `nc:write` | Write operations | Create notes, update events, delete files, modify contacts |
### Standard OIDC Scopes
| Scope | Description | Required |
|-------|-------------|----------|
| `openid` | OIDC authentication | Yes |
| `profile` | User profile information | Recommended |
| `email` | Email address | Recommended |
### Recommended Configurations
**Full Access:**
```
openid profile email nc:read nc:write
```
**Read-Only:**
```
openid profile email nc:read
```
**No Custom Scopes (OIDC only):**
```
openid profile email
```
### Implementation
All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires nc:read scope)"""
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires nc:write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
- ✅ 54 write tools decorated with `@require_scopes("nc:write")`
- ✅ 90/90 tools covered (100%)
### Dynamic Tool Filtering
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use:
**JWT with `nc:read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**JWT with `nc:write` only:**
- `list_tools()` returns 54 write-only tools
- Read tools are hidden from the tool list
**JWT with both scopes:**
- `list_tools()` returns all 90 tools
**JWT with no custom scopes:**
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
**BasicAuth mode:**
- `list_tools()` returns all 90 tools (no filtering)
### Scope Challenges
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="nc:write",
resource_metadata="http://server/.well-known/oauth-protected-resource"
```
This enables **step-up authorization** - clients can detect missing scopes and trigger re-authentication to obtain additional permissions.
### Protected Resource Metadata (PRM)
The server implements RFC 8959's Protected Resource Metadata endpoint:
**Endpoint:** `GET /.well-known/oauth-protected-resource`
**Response:**
```json
{
"resource": "http://localhost:8002",
"scopes_supported": ["nc:read", "nc:write"],
"authorization_servers": ["http://localhost:8080"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
}
```
This allows OAuth clients to discover supported scopes before requesting authorization.
---
## Configuration
### Docker Services
The development environment includes three MCP server variants:
| Service | Port | Auth Type | Token Type | Use Case |
|---------|------|-----------|------------|----------|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
### JWT Service Configuration
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
**Default Configuration (DCR):**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
volumes:
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
```
**With Pre-Configured Credentials:**
```yaml
mcp-oauth-jwt:
build: .
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
ports:
- 127.0.0.1:8002:8002
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
```
**Key Points:**
- **No credentials needed** - DCR automatically registers the client on first start
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
- **JWT tokens** - Set `TOKEN_TYPE=jwt` for better performance
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `NEXTCLOUD_HOST` | Nextcloud base URL | `http://localhost:8080` |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server external URL for OAuth callbacks | (required in OAuth mode) |
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email nc:read nc:write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
### Dynamic Client Registration (DCR)
The MCP server supports **automatic OAuth client registration** using the OIDC Discovery registration endpoint. This eliminates the need for manual client creation in most cases.
**How It Works:**
When the MCP server starts in OAuth mode, it follows this **three-tier credential loading strategy**:
```
1. Environment Variables (Highest Priority)
├─ NEXTCLOUD_OIDC_CLIENT_ID
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
2. Storage File (Second Priority)
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
3. Dynamic Client Registration (Automatic Fallback)
├─ Discovers registration endpoint from /.well-known/openid-configuration
├─ Registers new client with requested scopes and token type
├─ Saves credentials to storage file for future use
└─ Client credentials persist across restarts
```
**Configuration:**
DCR automatically configures the client based on environment variables:
```bash
# Minimal DCR configuration (no credentials needed!)
export NEXTCLOUD_HOST=http://localhost:8080
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write"
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
```
**Credential Storage:**
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
- File has restrictive permissions (0600 - owner read/write only)
- Credentials are reused on subsequent starts (no re-registration needed)
- Storage file is checked for expiration (auto-regenerates if expired)
**Format:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"client_id_issued_at": 1761097039,
"client_secret_expires_at": 2076457039,
"redirect_uris": ["http://localhost:8000/oauth/callback"]
}
```
**Benefits:**
- ✅ Zero-configuration OAuth setup
- ✅ Automatic credential management
- ✅ Supports both JWT and opaque tokens
- ✅ Credentials persist across container restarts
- ✅ Automatic re-registration if credentials expire
- ✅ Properly sets `allowed_scopes` for JWT token validation
### Manual Client Creation
Manual client creation is **optional** but may be preferred when:
- You want explicit control over client configuration
- You're deploying to production environments with strict security policies
- You need to pre-provision OAuth clients before deployment
**Create Client via OCC Command:**
```bash
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email nc:read nc:write" \
"Nextcloud MCP Server" \
"http://localhost:8000/oauth/callback"
```
**Output:**
```json
{
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"token_type": "jwt",
"allowed_scopes": "openid profile email nc:read nc:write"
}
```
**Configure MCP Server with Pre-Configured Credentials:**
```bash
# Option 1: Environment variables (highest priority)
export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
# Option 2: Storage file (second priority)
# Save the JSON response to .nextcloud_oauth_client.json
# Server will automatically load it on startup
```
When credentials are provided via environment variables or storage file, **DCR is skipped**.
---
## Architecture
### Component Overview
```
┌──────────────────┐ OAuth Flow ┌──────────────────┐
│ OAuth Client │<─────────────────────>│ Nextcloud OIDC │
│ (Claude, etc) │ │ Server │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ JWT Access Token │
│ { │
│ "scope": "openid nc:read nc:write" │
│ ... │
│ } │
│ │
v │
┌────────────────────────────────────────────────────────────┐
│ Nextcloud MCP Server │
│ ┌───────────────────────────────────────────────────┐ │
│ │ NextcloudTokenVerifier │ │
│ │ - JWT signature verification (JWKS) │ │
│ │ - Introspection endpoint (opaque tokens) │ │
│ │ - Userinfo fallback (last resort) │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Dynamic Tool Filtering (list_tools) │ │
│ │ - Get user scopes from verified token │ │
│ │ - Filter tools based on @require_scopes metadata │ │
│ │ - Return only accessible tools │ │
│ └───────────────────┬───────────────────────────────┘ │
│ │ │
│ v │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Tool Execution (@require_scopes decorator) │ │
│ │ - Check token scopes before execution │ │
│ │ - Raise InsufficientScopeError if missing │ │
│ │ - Return 403 with WWW-Authenticate header │ │
│ └───────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
### Key Components
**1. Token Verification** (`nextcloud_mcp_server/auth/token_verifier.py`)
- **Three-tier validation strategy:**
1. **JWT verification** (lines 116-124): JWKS signature validation for JWT tokens
2. **Introspection** (lines 126-134): RFC 7662 endpoint for opaque tokens
3. **Userinfo fallback** (lines 137-142): Last resort if introspection unavailable
- Scope extraction from token payload (JWT) or introspection response (opaque)
- Token caching with TTL to reduce repeated validations
- Supports both access token formats transparently
**2. Scope Authorization** (`nextcloud_mcp_server/auth/scope_authorization.py`)
- `@require_scopes()` decorator for tools
- `get_required_scopes()` - Extract scope requirements from functions
- `has_required_scopes()` - Check if user has necessary scopes
- `InsufficientScopeError` exception for WWW-Authenticate challenges
**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:433-488`)
- Overrides FastMCP's `list_tools()` method
- Filters based on user's JWT token scopes
- Only active in OAuth mode
- Bypassed in BasicAuth mode
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
- `GET /.well-known/oauth-protected-resource`
- Advertises `["nc:read", "nc:write"]`
- RFC 8959 compliant
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
- Catches `InsufficientScopeError`
- Returns 403 with `WWW-Authenticate` header
- Includes missing scopes and PRM endpoint URL
### Token Validation Flow
The `NextcloudTokenVerifier` implements a **cascading validation strategy** that handles both JWT and opaque tokens efficiently:
```
┌─────────────────────────────────────────────────────────┐
│ verify_token(token) │
│ (nextcloud_mcp_server/auth/token_verifier.py:88-142) │
└────────────────────────┬────────────────────────────────┘
├──> 1. Check cache (lines 106-109)
│ ├─ Hit: Return cached AccessToken
│ └─ Miss: Continue to validation
├──> 2. JWT Format Check (lines 112-124)
│ ├─ Token has 3 parts (header.payload.signature)?
│ │ └─ Yes: Attempt JWT verification
│ │ ├─ Verify signature with JWKS (RS256)
│ │ ├─ Validate issuer, expiration
│ │ ├─ Extract scopes from payload
│ │ └─ Success: Return AccessToken
│ └─ Fail/Not JWT: Continue to introspection
├──> 3. Introspection (lines 126-134)
│ ├─ POST to /apps/oidc/introspect
│ ├─ Authenticate with client credentials
│ ├─ Response contains:
│ │ • active: true/false
│ │ • scope: "openid nc:read nc:write"
│ │ • sub, exp, iat, client_id
│ ├─ Extract scopes from response
│ └─ Success: Return AccessToken
└──> 4. Userinfo Fallback (lines 137-142)
├─ GET /apps/oidc/userinfo
├─ Bearer token in Authorization header
├─ Infer scopes from response claims
└─ Return AccessToken or None
```
**Validation Priorities:**
| Token Type | Method | Performance | Scope Access | Code Reference |
|------------|--------|-------------|--------------|----------------|
| JWT | JWKS Signature | ⚡ Fastest (local) | Direct (`scope` claim) | `token_verifier.py:156-234` |
| Opaque | Introspection | 🔄 Medium (HTTP) | Direct (`scope` field) | `token_verifier.py:236-328` |
| Any | Userinfo | 🐌 Slowest (HTTP + inference) | Inferred (from claims) | `token_verifier.py:330-386` |
**Configuration** (`nextcloud_mcp_server/app.py:391-399`):
```python
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enables JWT verification
issuer=jwt_validation_issuer, # For JWT issuer validation
introspection_uri=introspection_uri, # Enables introspection for opaque tokens
client_id=client_id, # Required for introspection auth
client_secret=client_secret, # Required for introspection auth
)
```
## Testing
### Test Infrastructure
The test suite includes comprehensive coverage for JWT OAuth and scope authorization:
**Test Files:**
- `tests/server/test_scope_authorization.py` - Scope-based authorization tests (4 tests)
- `tests/server/test_mcp_oauth_jwt.py` - JWT OAuth integration tests
- `tests/conftest.py` - Shared fixtures for JWT testing
### Consent Scenario Tests
Four test scenarios verify scope-based tool filtering with different consent levels:
#### 1. No Custom Scopes (0 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_scopes_returns_zero_tools -v
```
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
**Expected:** 0 tools returned (all require `nc:read` or `nc:write`)
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
#### 2. Read-Only Access (36 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
```
**Scenario:** JWT token with `nc:read` only
**Expected:** 36 read-only tools visible, write tools hidden
**Verifies:** Read tools accessible, write tools filtered out
#### 3. Write-Only Access (54 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
```
**Scenario:** JWT token with `nc:write` only
**Expected:** 54 write tools visible, read tools hidden
**Verifies:** Write tools accessible, read tools filtered out
#### 4. Full Access (90 tools)
```bash
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
```
**Scenario:** JWT token with both `nc:read` and `nc:write`
**Expected:** All 90 tools visible
**Verifies:** Full access when user grants all custom scopes
### Test Fixtures
**OAuth Client Fixtures:**
- `read_only_oauth_client_credentials` - Client with `nc:read` only
- `write_only_oauth_client_credentials` - Client with `nc:write` only
- `full_access_oauth_client_credentials` - Client with both scopes
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
**Token Fixtures:**
- `playwright_oauth_token_read_only` - Obtains token with `nc:read`
- `playwright_oauth_token_write_only` - Obtains token with `nc:write`
- `playwright_oauth_token_full_access` - Obtains token with both scopes
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
**MCP Client Fixtures:**
- `nc_mcp_oauth_client_read_only` - MCP session with read-only token
- `nc_mcp_oauth_client_write_only` - MCP session with write-only token
- `nc_mcp_oauth_client_full_access` - MCP session with full access token
- `nc_mcp_oauth_client_no_custom_scopes` - MCP session with no custom scopes
### Running Tests
**All consent scenario tests:**
```bash
uv run pytest tests/server/test_scope_authorization.py -v
```
**JWT OAuth integration tests:**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox
```
**With visible browser (debugging):**
```bash
uv run pytest tests/server/test_mcp_oauth_jwt.py -v --browser firefox --headed
```
### Test Configuration
**Playwright Browser:**
- Default: Chromium
- Recommended for CI: Firefox (`--browser firefox`)
- Debugging: Add `--headed` flag
**OAuth Flow:**
- Uses automated Playwright browser automation
- Completes OAuth consent flow programmatically
- Creates separate OAuth client for each scenario
- Each user gets unique access token
---
## Troubleshooting
### Issue: JWT Issuer Validation Failed
**Symptom:**
```
WARNING JWT issuer validation failed: Invalid issuer
WARNING JWT verification failed, will try other methods
✅ Extracted scopes from access token: {'openid', 'profile'}
```
**Cause:** Token's `iss` claim doesn't match expected issuer URL. This often happens when:
- Using `localhost` vs `127.0.0.1` inconsistently
- MCP server uses internal URL but clients use public URL
**Solution:**
```bash
# Option 1: Use consistent URLs
export NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Ensure all test fixtures also use localhost:8080
# Option 2: Check discovery document
curl http://localhost:8080/.well-known/openid-configuration | jq .issuer
# Use this exact issuer in NEXTCLOUD_PUBLIC_ISSUER_URL
```
**Impact if not fixed:**
- JWT validation falls back to userinfo endpoint
- Scopes inferred from userinfo (only standard OIDC scopes, no custom scopes)
- Result: 0 tools visible or incorrect tool filtering
### Issue: Scopes Not Present in JWT
**Symptom:** JWT token doesn't contain `scope` claim or contains empty string
**Cause:** Client's `allowed_scopes` is empty or not configured
**Solution:**
```bash
# Check client configuration
docker compose exec app php occ oidc:list
# Look for allowed_scopes in output
# If empty, recreate client with --allowed_scopes
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email nc:read nc:write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `nc:read` token can see all 90 tools including write tools
**Cause:** Server running in BasicAuth mode, not OAuth mode
**Solution:**
```bash
# Verify OAuth mode is active
docker compose logs mcp-oauth-jwt | grep "OAuth mode"
# Should see: "Running in OAuth mode"
# If not, check environment variables:
docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
```
### Verifying DCR Scope Configuration
DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provided during registration.
**To verify DCR scopes are working:**
```bash
# Check the registered client's allowed_scopes via database
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
# Should show your requested scopes (e.g., "openid profile email nc:read nc:write")
```
**If scopes are missing:**
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
2. Check MCP server startup logs for the scopes being requested
3. Verify DCR is enabled in Nextcloud OIDC app settings
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
### Issue: Token Type Case Sensitivity
**Symptom:** JWT tokens not generated even though `token_type=JWT` set
**Cause:** OIDC app checks `token_type === 'jwt'` (lowercase)
**Solution:** Always use lowercase:
```bash
# Correct
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
# Incorrect (will generate opaque tokens)
export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
```
### Issue: Missing WWW-Authenticate Header
**Symptom:** 403 error doesn't include `WWW-Authenticate` header
**Cause:** Server not in OAuth mode, or exception not being caught
**Solution:**
```bash
# Check server logs for OAuth mode
docker compose logs mcp-oauth-jwt | grep "WWW-Authenticate scope challenges enabled"
# Should see this during startup
# Check exception handling
docker compose logs mcp-oauth-jwt | grep "InsufficientScopeError"
```
### Debugging Tools
**Check JWT contents:**
```bash
# Decode JWT (base64 decode the payload)
echo "JWT_PAYLOAD_PART" | base64 -d | jq .
```
**Check database scopes:**
```bash
# View access tokens with scopes
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT id, client_id, user_id, scope FROM oc_oidc_access_tokens ORDER BY id DESC LIMIT 5;"
# View user consents
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT user_id, client_id, scopes_granted FROM oc_oidc_user_consents;"
```
**Check server logs:**
```bash
# Follow JWT verification logs
docker compose logs -f mcp-oauth-jwt | grep -E "JWT|scope|tool"
# Check for issuer mismatches
docker compose logs mcp-oauth-jwt | grep -i issuer
```
---
## Production Deployment
### Deployment Checklist
**Use JWT Tokens** - Enable `token_type=jwt` for better performance
**Configure Allowed Scopes** - Always set `allowed_scopes` on OAuth clients
**Use Pre-Configured Clients** - Avoid DCR limitation with manual client creation
**Consistent URLs** - Use same URL for `NEXTCLOUD_HOST` and `PUBLIC_ISSUER_URL`
**Secure Credentials** - Store client credentials securely (environment variables or secrets management)
**Monitor Token Size** - JWT tokens are 10-15x larger than opaque (not usually an issue)
**Enable Logging** - Configure appropriate log levels for JWT verification
### Production Configuration Example
```yaml
# docker-compose.yml (production)
mcp-oauth-jwt:
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
environment:
- NEXTCLOUD_HOST=https://nextcloud.example.com
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
ports:
- "8002:8002"
```
### Security Considerations
**Token Storage:**
- Never commit credentials to version control
- Use environment variables or secrets management
- Rotate client secrets periodically
**Scope Configuration:**
- Grant minimum necessary scopes to clients
- Use read-only tokens for AI assistants that don't need write access
- Review OAuth client list regularly
**Network Security:**
- Use HTTPS in production
- Ensure issuer URL matches public URL
- Configure proper CORS headers
### Monitoring
**Key Metrics:**
- JWT verification success/failure rate
- Scope challenge frequency (indicates clients with insufficient scopes)
- Token validation latency
- Tool execution by scope (identify unused scopes)
**Log Patterns:**
```bash
# Success
INFO JWT verified successfully for user: admin
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'nc:read', 'nc:write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: nc:write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc:write` (not per-app scopes)
2. **No Refresh Token Support** - Tokens must be reacquired when expired
### Future Enhancements
**Potential Improvements:**
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
- Resource-level filtering (apply to MCP resources, not just tools)
- Automatic scope discovery from decorated tools
- Admin UI for scope management
---
## References
### Standards
- [RFC 9068: JWT Profile for OAuth 2.0 Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
- [RFC 7519: JSON Web Token (JWT)](https://www.rfc-editor.org/rfc/rfc7519.html)
- [RFC 7517: JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517.html)
- [RFC 8959: Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc8959.html)
- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662.html)
### Related Documentation
- [OAuth Setup Guide](oauth-setup.md) - Complete OAuth configuration guide
- [OAuth Architecture](oauth-architecture.md) - Detailed architecture documentation
- [OAuth Troubleshooting](oauth-troubleshooting.md) - Common OAuth issues and solutions
- [Authentication Guide](authentication.md) - BasicAuth vs OAuth comparison
### External Resources
- [Nextcloud OIDC App](https://github.com/H2CK/oidc) - OIDC identity provider for Nextcloud
- [PyJWT Documentation](https://pyjwt.readthedocs.io/) - JWT library used for verification
- [FastMCP Documentation](https://github.com/jlowin/fastmcp) - MCP server framework
---
**Implementation Date:** 2025-10-21 to 2025-10-23
**Version:** 1.0.0
**Status:** ✅ Production Ready
+6 -6
View File
@@ -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
+412
View File
@@ -0,0 +1,412 @@
# Testing OIDC Consent Feature
This guide explains how to test the OIDC consent feature using the development version of the OIDC app mounted into the Docker environment.
## Setup
### Volume Mount Configuration
The development OIDC app is mounted from `~/Software/oidc` into the container at `/opt/apps/oidc`:
```yaml
# docker-compose.yml
volumes:
- ../Software/oidc:/opt/apps/oidc:ro
```
**Why mount outside `/var/www/html/`?**
- The Nextcloud container uses `rsync` to initialize `/var/www/html/` from the image
- Mounting inside that path causes conflicts (rsync tries to delete mounted directories)
- Mounting to `/opt/apps/oidc` avoids rsync entirely
- Nextcloud supports multiple app directories via the `apps_paths` configuration
**How multiple app paths work:**
- Nextcloud can load apps from multiple directories
- The post-installation hook registers `/opt/apps` as an additional app directory (index 2)
- Apps in default paths (index 0 and 1) are still available
- All directories are scanned for apps, but `/opt/apps` is read-only
This setup allows you to:
- Test changes without rebuilding containers
- Avoid needing npm/node in the container (JS already built on host)
- Iterate quickly on development
- Install other Nextcloud apps normally (custom_apps remains writable)
### How It Works
1. **Mount Development App**: Docker mounts `~/Software/oidc` to `/opt/apps/oidc` (outside Nextcloud's path)
2. **Register App Path**: The `10-install-oidc-app.sh` hook configures `/opt/apps` as an additional app directory
3. **Enable App**: The hook enables the OIDC app from `/opt/apps/oidc`
4. **Run Migrations**: Nextcloud detects pending migrations and runs them automatically
5. **Configure OIDC**: Dynamic client registration and PKCE are enabled
## Starting the Stack
```bash
cd ~/Projects/nextcloud-mcp-server
# Start fresh (recommended for first test)
docker compose down -v
docker compose up -d
# Wait for initialization (check logs)
docker compose logs -f app
```
The post-installation hooks will:
1. Configure custom_apps path (already done)
2. Enable OIDC app from mounted directory
3. Run database migrations (including consent table creation)
4. Configure OIDC settings
## Verifying Installation
### Before Container Restart
Before running `docker compose up -d`, the consent feature will NOT be active:
- ❌ No `oc_oidc_user_consents` table in database
- ❌ Migration 0015 not applied yet
- ❌ ConsentController class not loaded
- ❌ Consent routes not registered
You can verify this with:
```bash
# Check migrations applied (should stop at 0014)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' ORDER BY version DESC LIMIT 3;" nextcloud
# Check for consent table (should return empty)
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';" nextcloud
```
### After Container Restart
After `docker compose up -d` with the mounted OIDC directory, the consent feature should be active:
-`oc_oidc_user_consents` table exists
- ✅ Migration 0015 (Version0015Date20251123100100) applied
- ✅ ConsentController routes registered
- ✅ Consent screen appears during OAuth flows
### Check App Status
```bash
docker compose exec app php occ app:list | grep -A 2 oidc
```
Expected output:
```
- oidc: 1.10.0 (enabled)
```
### Verify App Paths Configuration
Verify that `/opt/apps` is registered as an additional app directory:
```bash
# Check configured app paths
docker compose exec app php occ config:system:get apps_paths
# Verify the mount is accessible
docker compose exec app ls -la /opt/apps/oidc/
# Verify custom_apps is writable (for normal app installation)
docker compose exec -u www-data app touch /var/www/html/custom_apps/.test && echo "✅ custom_apps is writable" || echo "❌ custom_apps NOT writable"
docker compose exec app rm -f /var/www/html/custom_apps/.test
```
Expected: Output should show multiple app paths including index 2 (/opt/apps).
### Verify Consent Files
```bash
# Check controller exists in mounted location
docker compose exec app ls -la /opt/apps/oidc/lib/Controller/ConsentController.php
# Check Vue component exists
docker compose exec app ls -la /opt/apps/oidc/src/Consent.vue
# Check built JS exists
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
### Verify Database Migration
**Note**: These checks will only pass after restarting containers with the mounted OIDC app.
```bash
# Check if consent table exists
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SHOW TABLES LIKE 'oc_oidc_user_consents';"
# Check table structure
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "DESCRIBE oc_oidc_user_consents;"
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';"
```
Expected table structure:
- id: int(10) unsigned, auto_increment, primary key
- user_id: varchar(256), not null
- client_id: int(10) unsigned, not null
- scopes_granted: varchar(512), not null
- created_at: int(10) unsigned, not null
- updated_at: int(10) unsigned, not null
- expires_at: int(10) unsigned, nullable
### Verify Routes
```bash
docker compose exec app php occ router:list | grep consent
```
Expected output:
```
oidc.Consent.show GET apps/oidc/consent
oidc.Consent.grant POST apps/oidc/consent/grant
oidc.Consent.deny POST apps/oidc/consent/deny
```
## Testing the Consent Flow
### 1. Create an OAuth Client
The JWT client is automatically created by the post-installation hooks:
```bash
# Check if JWT client exists
docker compose exec app cat /var/www/html/.oauth-jwt/nextcloud_oauth_client.json
```
### 2. Initiate Authorization Flow
You can test using the MCP OAuth container or manually:
**Option A: Using MCP OAuth container**
```bash
# The mcp-oauth-jwt container will trigger the OAuth flow
docker compose logs -f mcp-oauth-jwt
```
**Option B: Manual browser test**
1. Get client_id from the JWT client JSON
2. Visit in browser:
```
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8002/oauth/callback&scope=openid+profile+email+nc:read+nc:write&state=test123
```
### 3. Expected Behavior
**First Authorization:**
1. User logs in (if not already authenticated)
2. **Consent screen appears** with:
- Application name: "Nextcloud MCP Server JWT"
- List of requested scopes with descriptions:
- ✓ Basic authentication (openid) - required, cannot deselect
- ✓ Profile information (profile)
- ✓ Email address (email)
- ✓ nc:read (custom scope, shown as-is)
- ✓ nc:write (custom scope, shown as-is)
- "Allow" and "Deny" buttons
3. User selects scopes and clicks "Allow"
4. Authorization proceeds with selected scopes
5. Consent is stored in database
**Subsequent Authorizations:**
- Same scopes → No consent screen (uses stored consent)
- Different scopes → Consent screen appears again
- If user clicks "Deny" → Returns `error=access_denied` to client
### 4. Verify Consent Stored
After granting consent:
```bash
# View all stored consents with formatted timestamps
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "
SELECT
user_id,
client_id,
scopes_granted,
FROM_UNIXTIME(created_at) as created,
FROM_UNIXTIME(updated_at) as updated,
FROM_UNIXTIME(expires_at) as expires
FROM oc_oidc_user_consents;
" nextcloud
# Or for a compact view:
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT * FROM oc_oidc_user_consents;" nextcloud
```
## Troubleshooting
### Consent Screen Not Appearing
**Check browser console** (F12 → Console tab):
```
# Look for JS errors like:
Failed to load resource: js/oidc-consent.js
```
**Check Nextcloud logs:**
```bash
docker compose exec app tail -f /var/www/html/data/nextcloud.log | grep -i consent
```
**Verify JS file loaded:**
```bash
# Check file exists and has correct size (~73KB)
docker compose exec app ls -lh /opt/apps/oidc/js/oidc-consent.js
```
**Clear Nextcloud caches:**
```bash
docker compose exec app php occ maintenance:repair
docker compose restart app
```
### Migration Didn't Run
**Check which migrations have been applied:**
```bash
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT app, version FROM oc_migrations WHERE app = 'oidc' ORDER BY version;" nextcloud
```
Expected to see `Version0015Date20251123100100` in the list.
**Manually trigger migrations:**
```bash
# Disable and re-enable app (triggers all pending migrations)
docker compose exec app php occ app:disable oidc
docker compose exec app php occ app:enable oidc
# Verify migration 0015 was applied
docker compose exec -T db mariadb -u nextcloud -ppassword nextcloud -e "SELECT version FROM oc_migrations WHERE app = 'oidc' AND version LIKE '%0015%';" nextcloud
```
### Routes Not Registered
If `router:list` doesn't show consent routes:
```bash
# The autoloader might not have picked up new classes
# Restart the container
docker compose restart app
# Wait for it to be ready
sleep 10
# Try again
docker compose exec app php occ router:list | grep consent
```
If still not working, check if ConsentController is accessible:
```bash
docker compose exec app php -r "
require_once '/var/www/html/lib/base.php';
\$class = 'OCA\\OIDCIdentityProvider\\Controller\\ConsentController';
if (class_exists(\$class)) {
echo \"Class exists\n\";
} else {
echo \"Class not found\n\";
}
"
```
## Making Changes
### Frontend Changes (Vue.js)
1. Edit source file on host:
```bash
cd ~/Software/oidc
# Edit src/Consent.vue
```
2. Rebuild JS:
```bash
npm run build
```
3. Refresh browser (container sees changes immediately via volume mount at /opt/apps/oidc)
### Backend Changes (PHP)
1. Edit files on host:
```bash
cd ~/Software/oidc
# Edit lib/Controller/ConsentController.php or other PHP files
```
2. Changes are immediately visible (PHP is interpreted, no build step)
3. For new classes or major changes, restart container:
```bash
docker compose restart app
```
### Database Schema Changes
If you modify the migration:
```bash
# Changes won't be picked up if migration already ran
# Need to recreate the database:
docker compose down -v # Removes volumes
docker compose up -d # Fresh start with clean DB
```
## Cleanup
### Reset Everything
```bash
cd ~/Projects/nextcloud-mcp-server
docker compose down -v
```
This removes:
- All containers
- Database volume (all data)
- OAuth client credentials
### Keep Data, Restart App
```bash
docker compose restart app
```
This preserves:
- Database (consents, clients, users)
- OAuth client credentials
## Development Workflow Summary
1. **Make changes** in `~/Software/oidc`
2. **Build JS** if you changed Vue files: `npm run build`
3. **Test immediately** - refresh browser or restart container
4. **No need** to rebuild Docker images or reinstall app
5. **Iterate quickly** with instant feedback
## Production Deployment
When ready to deploy:
1. **Create patch file** (already done):
```bash
cd ~/Software/oidc
git format-patch master --stdout > user-consent-feature.patch
```
2. **Test patch** in clean environment:
```bash
# In a production-like environment
cd /path/to/production/oidc
git apply user-consent-feature.patch
npm install
npm run build
php occ app:disable oidc
php occ app:enable oidc
```
3. **Verify migration** runs automatically on app enable
4. **Submit pull request** to upstream repository
+334 -77
View File
@@ -11,9 +11,16 @@ from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client
from nextcloud_mcp_server.auth import (
InsufficientScopeError,
NextcloudTokenVerifier,
get_access_token_scopes,
has_required_scopes,
is_jwt_token,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
@@ -135,6 +142,95 @@ def is_oauth_mode() -> bool:
return True
async def load_oauth_client_credentials(
nextcloud_host: str, registration_endpoint: str | None
) -> tuple[str, str]:
"""
Load OAuth client credentials from environment, storage file, or dynamic registration.
This consolidates the client loading logic that was duplicated across multiple functions.
Args:
nextcloud_host: Nextcloud instance URL
registration_endpoint: Dynamic registration endpoint URL (or None if not available)
Returns:
Tuple of (client_id, client_secret)
Raises:
ValueError: If credentials cannot be obtained
"""
# Try environment variables first
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials from environment")
return (client_id, client_secret)
# Try loading from storage file
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
from pathlib import Path
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
client_info = load_client_from_file(Path(storage_path))
if client_info:
logger.info(
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
)
return (client_info.client_id, client_info.client_secret)
# Try dynamic registration if available
if registration_endpoint:
logger.info("Dynamic client registration available")
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Get scopes from environment or use defaults
scopes = os.getenv(
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
)
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
# Note: Must be lowercase "jwt" to match OIDC app's check
token_type = os.getenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "Bearer").lower()
# Special case: "bearer" should remain capitalized for compatibility
if token_type != "jwt":
token_type = "Bearer"
logger.info(f"Requesting token type: {token_type}")
# Load or register client
from nextcloud_mcp_server.auth.client_registration import (
load_or_register_client,
)
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
return (client_info.client_id, client_info.client_secret)
# No credentials available
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app"
)
@asynccontextmanager
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
@@ -187,45 +283,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
introspection_uri = discovery.get("introspection_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
if introspection_uri:
logger.info(f"Introspection endpoint: {introspection_uri}")
# Handle client registration
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials")
elif registration_endpoint:
logger.info("Dynamic client registration available")
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Load or register client
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
redirect_uris=redirect_uris,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
else:
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
"2. Dynamic client registration enabled on Nextcloud OIDC app"
)
# Create token verifier
# Create token verifier with introspection support
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
introspection_uri=introspection_uri,
client_id=client_id,
client_secret=client_secret,
)
logger.info("OAuth initialization complete")
@@ -278,59 +353,60 @@ async def setup_oauth_config():
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
# Allow override of public issuer URL for clients
# (useful when MCP server accesses Nextcloud via internal URL
# but needs to advertise a different URL to clients)
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(f"Using public issuer URL for clients: {public_issuer}")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
# Handle client registration
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info("Using pre-configured OAuth client credentials")
elif registration_endpoint:
logger.info("Dynamic client registration available")
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
# Load or register client
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage_path=storage_path,
client_name="Nextcloud MCP Server",
redirect_uris=redirect_uris,
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
jwt_validation_issuer = public_issuer
else:
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
"2. Dynamic client registration enabled on Nextcloud OIDC app"
)
# Use discovered issuer for both
jwt_validation_issuer = issuer
# Create token verifier
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: We don't set required_scopes here anymore.
# Scopes are now advertised via PRM endpoint and enforced per-tool.
# This allows dynamic tool filtering based on user's actual token scopes.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(mcp_server_url),
required_scopes=["openid", "profile"],
)
logger.info("OAuth configuration complete")
@@ -393,6 +469,54 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
# Override list_tools to filter based on user's token scopes (OAuth mode only)
if oauth_enabled:
original_list_tools = mcp._tool_manager.list_tools
def list_tools_filtered():
"""List tools filtered by user's token scopes (JWT tokens only)."""
# Get user's scopes from token using MCP SDK's contextvar
# This works for all request types including list_tools
user_scopes = get_access_token_scopes()
is_jwt = is_jwt_token()
logger.info(
f"🔍 list_tools called - Token type: {'JWT' if is_jwt else 'opaque/none'}, "
f"User scopes: {user_scopes}"
)
# Get all tools
all_tools = original_list_tools()
# Only filter for JWT tokens (opaque tokens show all tools)
# JWT tokens have scopes embedded, so we can reliably filter
# Opaque tokens may not have accurate scope information from introspection
if is_jwt and user_scopes:
allowed_tools = [
tool
for tool in all_tools
if has_required_scopes(tool.fn, user_scopes)
]
logger.info(
f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools "
f"available for scopes: {user_scopes}"
)
else:
# Opaque token, BasicAuth mode, or no token - show all tools
allowed_tools = all_tools
reason = (
"opaque token (no filtering)"
if not is_jwt and user_scopes
else "no token/BasicAuth"
)
logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})")
# Return the Tool objects directly (they're already in the correct format)
return allowed_tools
# Replace the tool manager's list_tools method
mcp._tool_manager.list_tools = list_tools_filtered
logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)")
if transport == "sse":
mcp_app = mcp.sse_app()
lifespan = None
@@ -405,7 +529,71 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
await stack.enter_async_context(mcp.session_manager.run())
yield
app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan)
# Add Protected Resource Metadata (PRM) endpoint for OAuth mode
routes = []
if oauth_enabled:
def oauth_protected_resource_metadata(request):
"""RFC 8959 Protected Resource Metadata endpoint."""
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
return JSONResponse(
{
"resource": mcp_server_url,
"scopes_supported": ["nc:read", "nc:write"],
"authorization_servers": [public_issuer_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
)
routes.append(
Route(
"/.well-known/oauth-protected-resource",
oauth_protected_resource_metadata,
methods=["GET"],
)
)
logger.info("Protected Resource Metadata (PRM) endpoint enabled")
routes.append(Mount("/", app=mcp_app))
app = Starlette(routes=routes, lifespan=lifespan)
# Add exception handler for scope challenges (OAuth mode only)
if oauth_enabled:
@app.exception_handler(InsufficientScopeError)
async def handle_insufficient_scope(request, exc: InsufficientScopeError):
"""Return 403 with WWW-Authenticate header for scope challenges."""
resource_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
scope_str = " ".join(exc.missing_scopes)
return JSONResponse(
status_code=403,
headers={
"WWW-Authenticate": (
f'Bearer error="insufficient_scope", '
f'scope="{scope_str}", '
f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"'
)
},
content={
"error": "insufficient_scope",
"scopes_required": exc.missing_scopes,
},
)
logger.info("WWW-Authenticate scope challenge handler enabled")
return app
@@ -471,6 +659,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 nc:read nc:write",
show_default=True,
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@click.option(
"--oauth-token-type",
envvar="NEXTCLOUD_OIDC_TOKEN_TYPE",
default="bearer",
show_default=True,
type=click.Choice(["bearer", "jwt"], case_sensitive=False),
help="OAuth token type (can also use NEXTCLOUD_OIDC_TOKEN_TYPE env var)",
)
@click.option(
"--public-issuer-url",
envvar="NEXTCLOUD_PUBLIC_ISSUER_URL",
help="Public issuer URL for OAuth (can also use NEXTCLOUD_PUBLIC_ISSUER_URL env var)",
)
def run(
host: str,
port: int,
@@ -482,6 +705,12 @@ def run(
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
nextcloud_host: str | None,
nextcloud_username: str | None,
nextcloud_password: str | None,
oauth_scopes: str,
oauth_token_type: str,
public_issuer_url: str | None,
):
"""
Run the Nextcloud MCP server.
@@ -493,24 +722,52 @@ def run(
\b
Examples:
# BasicAuth mode (legacy)
# BasicAuth mode with CLI options
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com \\
--nextcloud-username=admin --nextcloud-password=secret
# BasicAuth mode with env vars (recommended for credentials)
$ export NEXTCLOUD_HOST=https://cloud.example.com
$ export NEXTCLOUD_USERNAME=admin
$ export NEXTCLOUD_PASSWORD=secret
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
# OAuth mode with auto-registration
$ nextcloud-mcp-server --oauth
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth
# OAuth mode with pre-configured client
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-client-id=xxx --oauth-client-secret=yyy
# OAuth mode with custom scopes and JWT tokens
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
# OAuth with public issuer URL (for Docker/proxy setups)
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
--public-issuer-url=http://localhost:8080
"""
# Set OAuth env vars from CLI options if provided
# Set env vars from CLI options if provided
if nextcloud_host:
os.environ["NEXTCLOUD_HOST"] = nextcloud_host
if nextcloud_username:
os.environ["NEXTCLOUD_USERNAME"] = nextcloud_username
if nextcloud_password:
os.environ["NEXTCLOUD_PASSWORD"] = nextcloud_password
if oauth_client_id:
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
if oauth_client_secret:
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
if oauth_storage_path:
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
if oauth_scopes:
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
if oauth_token_type:
os.environ["NEXTCLOUD_OIDC_TOKEN_TYPE"] = oauth_token_type
if mcp_server_url:
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
if public_issuer_url:
os.environ["NEXTCLOUD_PUBLIC_ISSUER_URL"] = public_issuer_url
# Force OAuth mode if explicitly requested
if oauth is True:
+18
View File
@@ -3,6 +3,16 @@
from .bearer_auth import BearerAuth
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
ScopeAuthorizationError,
check_scopes,
get_access_token_scopes,
get_required_scopes,
has_required_scopes,
is_jwt_token,
require_scopes,
)
from .token_verifier import NextcloudTokenVerifier
__all__ = [
@@ -11,4 +21,12 @@ __all__ = [
"register_client",
"load_or_register_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
"InsufficientScopeError",
"check_scopes",
"get_access_token_scopes",
"get_required_scopes",
"has_required_scopes",
"is_jwt_token",
]
@@ -68,6 +68,7 @@ async def register_client(
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
@@ -78,6 +79,7 @@ async def register_client(
client_name: Name of the client application
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
scopes: Space-separated list of scopes to request
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with registration details
@@ -96,6 +98,7 @@ async def register_client(
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": scopes,
"token_type": token_type,
}
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
@@ -215,6 +218,8 @@ async def load_or_register_client(
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Load client from storage or register a new one if not found/expired.
@@ -231,6 +236,8 @@ async def load_or_register_client(
storage_path: Path to store client credentials
client_name: Name of the client application
redirect_uris: List of redirect URIs
scopes: Space-separated list of scopes to request (default: "openid profile email")
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
Returns:
ClientInfo with valid credentials
@@ -253,6 +260,8 @@ async def load_or_register_client(
registration_endpoint=registration_endpoint,
client_name=client_name,
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
)
# Save to storage
@@ -0,0 +1,278 @@
"""Scope-based authorization for MCP tools."""
import logging
from functools import wraps
from typing import Callable
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
logger = logging.getLogger(__name__)
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
pass
class InsufficientScopeError(ScopeAuthorizationError):
"""Raised when request lacks required scopes (enables step-up auth).
This exception triggers a 403 response with WWW-Authenticate header
containing the missing scopes, allowing clients to perform step-up
authorization to obtain additional permissions.
"""
def __init__(self, missing_scopes: list[str], message: str | None = None):
self.missing_scopes = missing_scopes
super().__init__(
message or f"Missing required scopes: {', '.join(missing_scopes)}"
)
def require_scopes(*required_scopes: str):
"""
Decorator to require specific OAuth scopes for MCP tool execution.
This decorator:
1. Stores scope requirements as function metadata (_required_scopes attribute)
2. Checks that the access token contains all required scopes before execution
3. Raises ScopeAuthorizationError if any required scope is missing
The stored metadata enables dynamic tool filtering - tools can be hidden from
users who lack the necessary scopes.
Args:
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
Returns:
Decorated function that checks scopes before execution
Example:
```python
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the nc:read scope
...
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the nc:write scope
...
```
Raises:
ScopeAuthorizationError: If required scopes are not present in the access token
"""
def decorator(func: Callable):
# Store scope requirements as function metadata for dynamic filtering
func._required_scopes = list(required_scopes) # type: ignore
# Find which parameter receives the Context (FastMCP injects it by name)
context_param_name = find_context_parameter(func)
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract context from kwargs (where FastMCP injected it)
ctx: Context | None = (
kwargs.get(context_param_name) if context_param_name else None
)
if ctx is None:
# No context parameter found - likely BasicAuth mode
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No context parameter for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Check if we're in OAuth mode (access token available)
access_token: AccessToken | None = getattr(
ctx.request_context, "access_token", None
)
if access_token is None:
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No access token present for {func.__name__} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
# Check if all required scopes are present
missing_scopes = required_scopes_set - token_scopes
if missing_scopes:
error_msg = (
f"Access denied to {func.__name__}: "
f"Missing required scopes: {', '.join(sorted(missing_scopes))}. "
f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}"
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing_scopes), error_msg)
# All required scopes present - allow execution
logger.debug(
f"Scope authorization passed for {func.__name__}: {required_scopes}"
)
return await func(*args, **kwargs)
return wrapper
return decorator
def get_access_token_scopes(ctx: Context | None = None) -> set[str]:
"""
Extract scopes from the authenticated user's access token.
This function uses MCP SDK's contextvar to access the token, which works
across all request types including list_tools.
Args:
ctx: FastMCP context object (unused, kept for compatibility)
Returns:
Set of scope strings, empty set if no token or no scopes
"""
# Use MCP SDK's get_access_token() which uses contextvars
# This works for all request types, including list_tools
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found in auth context (likely BasicAuth mode)")
return set()
scopes = set(access_token.scopes or [])
logger.info(f"✅ Extracted scopes from access token: {scopes}")
return scopes
def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
"""
Check if the request context has all required scopes.
Utility function for manual scope checking without decorator.
Args:
ctx: FastMCP context object
*required_scopes: Variable number of required scope strings
Returns:
Tuple of (has_all_scopes: bool, missing_scopes: set[str])
Example:
```python
async def my_tool(ctx: Context):
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
if not has_scopes:
# Handle missing scopes
...
```
"""
token_scopes = get_access_token_scopes(ctx)
# If no access token, assume BasicAuth mode (all operations allowed)
if not token_scopes and getattr(ctx.request_context, "access_token", None) is None:
return True, set()
required_scopes_set = set(required_scopes)
missing_scopes = required_scopes_set - token_scopes
return len(missing_scopes) == 0, missing_scopes
def get_required_scopes(func: Callable) -> list[str]:
"""
Extract required scopes from a function decorated with @require_scopes.
Args:
func: Function to check (may be decorated)
Returns:
List of required scope strings, empty list if no scopes required
Example:
```python
@require_scopes("nc:read", "nc:write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
```
"""
return getattr(func, "_required_scopes", [])
def is_jwt_token() -> bool:
"""
Check if the current access token is in JWT format.
JWT tokens have 3 parts separated by dots (header.payload.signature).
Opaque tokens are random strings without this structure.
Returns:
True if current token is JWT format, False if opaque or no token
"""
access_token: AccessToken | None = get_access_token()
if access_token is None:
logger.debug("No access token found - not JWT")
return False
# JWT tokens have exactly 2 dots (3 parts)
token_string = access_token.token
is_jwt = "." in token_string and token_string.count(".") == 2
logger.debug(f"Token format check: is_jwt={is_jwt}")
return is_jwt
def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
"""
Check if a user has all scopes required by a function.
Used for dynamic tool filtering - determines if a tool should be visible
to a user based on their token scopes.
Args:
func: Function decorated with @require_scopes
user_scopes: Set of scopes the user possesses
Returns:
True if user has all required scopes (or no scopes required), False otherwise
Example:
```python
@require_scopes("nc:write")
async def create_note():
pass
user_scopes = {"nc:read", "nc:write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"nc:read"}
can_see = has_required_scopes(create_note, limited_user_scopes) # False
```
"""
required = get_required_scopes(func)
# No scopes required → always allow
if not required:
return True
# Empty user_scopes but scopes required → deny
if not user_scopes:
return False
# Check if user has all required scopes
return set(required).issubset(user_scopes)
+264 -13
View File
@@ -5,6 +5,8 @@ import time
from typing import Any
import httpx
import jwt
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier
logger = logging.getLogger(__name__)
@@ -12,22 +14,33 @@ logger = logging.getLogger(__name__)
class NextcloudTokenVerifier(TokenVerifier):
"""
Validates access tokens using Nextcloud OIDC userinfo endpoint.
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
This verifier:
1. Calls the userinfo endpoint with the bearer token
2. Caches successful responses to avoid repeated API calls
3. Extracts username from the 'sub' or 'preferred_username' claim
4. Optionally supports JWT validation for performance (future enhancement)
This verifier supports both JWT and opaque tokens:
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
2. For opaque tokens: Falls back to userinfo endpoint validation
3. Caches successful responses to avoid repeated API calls/verifications
The userinfo endpoint validates the token and returns user claims if valid,
or returns HTTP 400/401 if the token is invalid or expired.
JWT validation provides:
- Faster validation (no HTTP call needed)
- Direct scope extraction from token payload
- Signature verification using JWKS
Userinfo fallback provides:
- Support for opaque tokens
- Backward compatibility
- Additional validation layer
"""
def __init__(
self,
nextcloud_host: str,
userinfo_uri: str,
jwks_uri: str | None = None,
issuer: str | None = None,
introspection_uri: str | None = None,
client_id: str | None = None,
client_secret: str | None = None,
cache_ttl: int = 3600,
):
"""
@@ -36,26 +49,52 @@ class NextcloudTokenVerifier(TokenVerifier):
Args:
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
userinfo_uri: Full URL to the userinfo endpoint
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
issuer: Expected issuer claim value (for JWT verification)
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
client_id: OAuth client ID (required for introspection)
client_secret: OAuth client secret (required for introspection)
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.userinfo_uri = userinfo_uri
self.jwks_uri = jwks_uri
self.issuer = issuer
self.introspection_uri = introspection_uri
self.client_id = client_id
self.client_secret = client_secret
self.cache_ttl = cache_ttl
# Cache: token -> (userinfo, expiry_timestamp)
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
# HTTP client for userinfo requests
# HTTP client for userinfo/introspection requests
self._client = httpx.AsyncClient(timeout=10.0)
# PyJWKClient for JWT verification (lazy initialization)
self._jwks_client: PyJWKClient | None = None
if jwks_uri:
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
# Introspection support
if introspection_uri and client_id and client_secret:
logger.info(f"Token introspection enabled: {introspection_uri}")
elif introspection_uri:
logger.warning(
"Introspection URI provided but missing client credentials - introspection disabled"
)
async def verify_token(self, token: str) -> AccessToken | None:
"""
Verify a bearer token by calling the userinfo endpoint.
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
This method:
1. Checks the cache first for recent validations
2. Calls the userinfo endpoint if not cached
3. Returns AccessToken with username stored in metadata
2. Attempts JWT verification if JWKS is configured and token looks like JWT
3. Falls back to introspection for opaque tokens (if configured)
4. Falls back to userinfo endpoint as last resort
5. Returns AccessToken with username and scopes
Args:
token: The bearer token to verify
@@ -69,13 +108,225 @@ class NextcloudTokenVerifier(TokenVerifier):
logger.debug("Token found in cache")
return cached
# Validate via userinfo endpoint
# Try JWT verification first if enabled and token looks like JWT
is_jwt_format = self._is_jwt_format(token)
logger.debug(
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
)
if self._jwks_client and is_jwt_format:
logger.debug("Attempting JWT verification...")
jwt_result = self._verify_jwt(token)
if jwt_result:
logger.info("Token validated via JWT verification")
return jwt_result
else:
logger.warning("JWT verification failed, will try other methods")
# For opaque tokens, try introspection if available
if self.introspection_uri and self.client_id and self.client_secret:
logger.debug("Attempting token introspection...")
try:
introspection_result = await self._verify_via_introspection(token)
if introspection_result:
logger.info("Token validated via introspection")
return introspection_result
except Exception as e:
logger.warning(f"Introspection failed: {e}")
# Fall back to userinfo endpoint validation (last resort)
logger.debug("Attempting userinfo endpoint validation...")
try:
return await self._verify_via_userinfo(token)
except Exception as e:
logger.warning(f"Token verification failed: {e}")
return None
def _is_jwt_format(self, token: str) -> bool:
"""
Check if token looks like a JWT (has 3 parts separated by dots).
Args:
token: The token to check
Returns:
True if token appears to be JWT format
"""
return "." in token and token.count(".") == 2
def _verify_jwt(self, token: str) -> AccessToken | None:
"""
Verify JWT token with signature validation using JWKS.
Args:
token: The JWT token to verify
Returns:
AccessToken if valid, None if invalid
"""
try:
# Get signing key from JWKS
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Verify and decode JWT
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True if self.issuer else False,
"verify_aud": False, # Skip audience validation for Bearer tokens
},
)
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
logger.debug(f"Full JWT payload: {payload}")
# Extract username (sub claim)
username = payload.get("sub")
if not username:
logger.error("No 'sub' claim found in JWT payload")
return None
# Extract scopes from scope claim (space-separated string)
scope_string = payload.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
)
# Extract expiration
exp = payload.get("exp")
if not exp:
logger.warning("No 'exp' claim in JWT, using default TTL")
exp = int(time.time() + self.cache_ttl)
# Cache the result
userinfo = {
"sub": username,
"scope": scope_string,
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
}
self._token_cache[token] = (userinfo, exp)
return AccessToken(
token=token,
client_id=payload.get("client_id", ""),
scopes=scopes,
expires_at=exp,
resource=username, # Store username in resource field (RFC 8707)
)
except jwt.ExpiredSignatureError:
logger.info("JWT token has expired")
return None
except jwt.InvalidIssuerError as e:
logger.warning(f"JWT issuer validation failed: {e}")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"JWT validation failed: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during JWT verification: {e}")
return None
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
"""
Validate token by calling the introspection endpoint (RFC 7662).
This method validates opaque tokens and retrieves their scopes.
Args:
token: The bearer token to introspect
Returns:
AccessToken if active, None if inactive or invalid
"""
try:
# Introspection requires client authentication
response = await self._client.post(
self.introspection_uri,
data={"token": token},
auth=(self.client_id, self.client_secret),
)
if response.status_code == 200:
introspection_data = response.json()
# Check if token is active
if not introspection_data.get("active", False):
logger.info("Token introspection returned inactive=false")
return None
logger.debug(
f"Token introspected successfully for user: {introspection_data.get('sub')}"
)
# Extract username
username = introspection_data.get("sub") or introspection_data.get(
"username"
)
if not username:
logger.error("No username found in introspection response")
return None
# Extract scopes (space-separated string)
scope_string = introspection_data.get("scope", "")
scopes = scope_string.split() if scope_string else []
logger.debug(f"Extracted scopes from introspection: {scopes}")
# Extract expiration
exp = introspection_data.get("exp")
if exp:
expiry = float(exp)
else:
logger.warning(
"No 'exp' in introspection response, using default TTL"
)
expiry = time.time() + self.cache_ttl
# Cache the result
cache_data = {
"sub": username,
"scope": scope_string,
**{
k: v
for k, v in introspection_data.items()
if k not in ["sub", "scope", "active"]
},
}
self._token_cache[token] = (cache_data, expiry)
return AccessToken(
token=token,
client_id=introspection_data.get("client_id", ""),
scopes=scopes,
expires_at=int(expiry),
resource=username,
)
elif response.status_code in (400, 401, 403):
logger.info(f"Token introspection failed: HTTP {response.status_code}")
return None
else:
logger.warning(
f"Unexpected response from introspection: {response.status_code}"
)
return None
except httpx.TimeoutException:
logger.error("Timeout while introspecting token")
return None
except httpx.RequestError as e:
logger.error(f"Network error while introspecting token: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error during token introspection: {e}")
return None
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
"""
Validate token by calling the userinfo endpoint.
+17
View File
@@ -4,6 +4,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.calendar import (
Calendar,
@@ -18,6 +19,7 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
client = get_client(ctx)
@@ -27,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_event(
calendar_name: str,
title: str,
@@ -102,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_events(
calendar_name: str,
ctx: Context,
@@ -203,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_get_event(
calendar_name: str,
event_uid: str,
@@ -214,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
return event_data
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_update_event(
calendar_name: str,
event_uid: str,
@@ -286,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_delete_event(
calendar_name: str,
event_uid: str,
@@ -296,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_meeting(
title: str,
date: str,
@@ -361,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_get_upcoming_events(
ctx: Context,
calendar_name: str = "", # Empty = all calendars
@@ -410,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
return all_events[:limit]
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_find_availability(
duration_minutes: int,
ctx: Context,
@@ -489,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_bulk_operations(
operation: str, # "update", "delete", "move"
ctx: Context,
@@ -737,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_manage_calendar(
action: str, # "create", "delete", "update", "list"
ctx: Context,
@@ -805,6 +817,7 @@ def configure_calendar_tools(mcp: FastMCP):
# ============= Todo/Task Tools =============
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_list_todos(
calendar_name: str,
ctx: Context,
@@ -849,6 +862,7 @@ def configure_calendar_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_create_todo(
calendar_name: str,
summary: str,
@@ -891,6 +905,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_update_todo(
calendar_name: str,
todo_uid: str,
@@ -950,6 +965,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_calendar_delete_todo(
calendar_name: str,
todo_uid: str,
@@ -969,6 +985,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool()
@require_scopes("nc:read")
async def nc_calendar_search_todos(
ctx: Context,
status: Optional[str] = None,
+8
View File
@@ -2,6 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
logger = logging.getLogger(__name__)
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_contacts_list_addressbooks(ctx: Context):
"""List all addressbooks for the user."""
client = get_client(ctx)
return await client.contacts.list_addressbooks()
@mcp.tool()
@require_scopes("nc:read")
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
client = get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool()
@require_scopes("nc:write")
async def nc_contacts_create_addressbook(
ctx: Context, *, name: str, display_name: str
):
@@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
"""Delete an addressbook."""
client = get_client(ctx)
return await client.contacts.delete_addressbook(name=name)
@mcp.tool()
@require_scopes("nc:write")
async def nc_contacts_create_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
):
@@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact."""
client = get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool()
@require_scopes("nc:write")
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
):
+14
View File
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.cookbook import (
Category,
@@ -70,6 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
@@ -126,6 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
client = get_client(ctx)
@@ -150,6 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
client = get_client(ctx)
@@ -174,6 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_create_recipe(
name: str,
description: str | None = None,
@@ -252,6 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_update_recipe(
recipe_id: int,
name: str | None = None,
@@ -340,6 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
) -> DeleteRecipeResponse:
@@ -374,6 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_search_recipes(
query: str, ctx: Context
) -> SearchRecipesResponse:
@@ -409,6 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
@@ -435,6 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
) -> ListRecipesResponse:
@@ -470,6 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
client = get_client(ctx)
@@ -494,6 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
) -> ListRecipesResponse:
@@ -527,6 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_set_config(
folder: str | None = None,
update_interval: int | None = None,
@@ -569,6 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
+26
View File
@@ -3,6 +3,7 @@ from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.deck import (
CardOperationResponse,
@@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
# Read Tools (converted from resources)
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards"""
client = get_client(ctx)
@@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
return boards
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
client = get_client(ctx)
@@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
return board
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
return stacks
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
client = get_client(ctx)
@@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
return stack
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]:
@@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
return []
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard:
@@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
return card
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board"""
client = get_client(ctx)
@@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
return board.labels
@mcp.tool()
@require_scopes("nc:read")
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
client = get_client(ctx)
@@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
# Create/Update/Delete Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_board(
ctx: Context, title: str, color: str
) -> CreateBoardResponse:
@@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
# Stack Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
) -> CreateStackResponse:
@@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_stack(
ctx: Context,
board_id: int,
@@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
) -> StackOperationResponse:
@@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_card(
ctx: Context,
board_id: int,
@@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_card(
ctx: Context,
board_id: int,
@@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
) -> CardOperationResponse:
@@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_reorder_card(
ctx: Context,
board_id: int,
@@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
# Label Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
) -> CreateLabelResponse:
@@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool()
@require_scopes("nc:write")
async def deck_update_label(
ctx: Context,
board_id: int,
@@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
) -> LabelOperationResponse:
@@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-Label Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
) -> CardOperationResponse:
@@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
# Card-User Assignment Tools
@mcp.tool()
@require_scopes("nc:write")
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
@@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
) -> CardOperationResponse:
+12 -4
View File
@@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.notes import (
AppendContentResponse,
@@ -84,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_create_note(
title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse:
"""Create a new note"""
"""Create a new note (requires nc:write scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.create_note(
@@ -129,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_update_note(
note_id: int,
etag: str,
@@ -137,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
category: str | None,
ctx: Context,
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category.
"""Update an existing note's title, content, or category (requires nc:write scope).
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
@@ -193,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
) -> AppendContentResponse:
@@ -242,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category."""
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
client = get_client(ctx)
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -287,8 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID"""
"""Get a specific note by its ID (requires nc:read scope)"""
client = get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
@@ -315,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]:
@@ -360,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
logger.info("Deleting note %s", note_id)
+6
View File
@@ -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("nc: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("nc: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("nc: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("nc: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("nc:write")
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
+7
View File
@@ -2,6 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
logger = logging.getLogger(__name__)
@@ -10,18 +11,21 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_tables_list_tables(ctx: Context):
"""List all tables available to the user"""
client = get_client(ctx)
return await client.tables.list_tables()
@mcp.tool()
@require_scopes("nc:read")
async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views"""
client = get_client(ctx)
return await client.tables.get_table_schema(table_id)
@mcp.tool()
@require_scopes("nc:read")
async def nc_tables_read_table(
table_id: int,
ctx: Context,
@@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
"""Insert a new row into a table.
@@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.create_row(table_id, data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
"""Update an existing row in a table.
@@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
return await client.tables.update_row(row_id, data)
@mcp.tool()
@require_scopes("nc:write")
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
client = get_client(ctx)
+12
View File
@@ -2,6 +2,7 @@ import logging
from mcp.server.fastmcp import Context, FastMCP
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse
@@ -11,6 +12,7 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
"""List files and directories in the specified NextCloud path.
@@ -24,6 +26,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.list_directory(path)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -62,6 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
}
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
):
@@ -89,6 +93,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -102,6 +107,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.create_directory(path)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -115,6 +121,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.delete_resource(path)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -134,6 +141,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:write")
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
):
@@ -153,6 +161,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_search_files(
ctx: Context,
scope: str = "",
@@ -268,6 +277,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_find_by_name(
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
@@ -294,6 +304,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_find_by_type(
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
@@ -320,6 +331,7 @@ def configure_webdav_tools(mcp: FastMCP):
)
@mcp.tool()
@require_scopes("nc:read")
async def nc_webdav_list_favorites(
ctx: Context, scope: str = "", limit: int | None = None
) -> SearchFilesResponse:
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.17.0"
version = "0.17.1"
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"}
@@ -18,6 +18,7 @@ dependencies = [
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
]
classifiers = [
"Development Status :: 4 - Beta",
@@ -40,7 +41,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
[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 = "ERROR"
log_level = "ERROR"
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Script to automatically add @require_scopes decorators to MCP tools.
This script parses server module files and adds appropriate scope decorators
based on the operation type (read vs write).
Usage:
python scripts/add_scope_decorators.py [--dry-run] [--file FILE]
"""
import argparse
import ast
import re
from pathlib import Path
from typing import List, Tuple
# Operation patterns for classification
READ_PATTERNS = [
r".*_get_.*",
r".*_get$",
r".*_list_.*",
r".*_list$",
r".*_search_.*",
r".*_search$",
r".*_read_.*",
r".*_read$",
r".*_find_.*",
r".*_find$",
r".*_fetch_.*",
r".*_fetch$",
r".*_retrieve_.*",
r".*_retrieve$",
]
WRITE_PATTERNS = [
r".*_create_.*",
r".*_create$",
r".*_update_.*",
r".*_update$",
r".*_delete_.*",
r".*_delete$",
r".*_append_.*",
r".*_append$",
r".*_modify_.*",
r".*_modify$",
r".*_set_.*",
r".*_set$",
r".*_add_.*",
r".*_add$",
r".*_remove_.*",
r".*_remove$",
r".*_edit_.*",
r".*_edit$",
r".*_move_.*",
r".*_move$",
r".*_copy_.*",
r".*_copy$",
r".*_upload_.*",
r".*_upload$",
r".*_download_.*",
r".*_download$",
r".*_share_.*",
r".*_share$",
r".*_unshare_.*",
r".*_unshare$",
r".*_bulk_.*", # Bulk operations are typically writes
]
def classify_operation(func_name: str) -> str | None:
"""Classify a function as read or write operation.
Args:
func_name: Function name to classify
Returns:
"nc:read", "nc:write", or None if cannot classify
"""
# Check write patterns first (more specific)
for pattern in WRITE_PATTERNS:
if re.match(pattern, func_name):
return "nc:write"
# Check read patterns
for pattern in READ_PATTERNS:
if re.match(pattern, func_name):
return "nc:read"
return None
def has_scope_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function already has @require_scopes decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if (
isinstance(decorator.func, ast.Name)
and decorator.func.id == "require_scopes"
):
return True
elif isinstance(decorator, ast.Name) and decorator.name == "require_scopes":
return True
return False
def has_mcp_tool_decorator(decorators: List[ast.expr]) -> bool:
"""Check if function has @mcp.tool() decorator."""
for decorator in decorators:
if isinstance(decorator, ast.Call):
if isinstance(decorator.func, ast.Attribute):
if decorator.func.attr == "tool":
return True
return False
def find_tools_needing_decorators(
file_path: Path, verbose: bool = False
) -> List[Tuple[str, int, str]]:
"""Find all tools that need scope decorators.
Returns:
List of (function_name, line_number, required_scope)
"""
with open(file_path) as f:
content = f.read()
try:
tree = ast.parse(content)
except SyntaxError as e:
print(f" ⚠️ Syntax error in {file_path}: {e}")
return []
tools_to_update = []
total_functions = 0
mcp_tools = 0
already_has_scope = 0
cannot_classify = 0
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
total_functions += 1
if verbose and node.decorator_list:
decorators_str = [
ast.unparse(d) if hasattr(ast, "unparse") else str(d)
for d in node.decorator_list
]
print(f" Function {node.name} has decorators: {decorators_str}")
# Check if it's an MCP tool
if not has_mcp_tool_decorator(node.decorator_list):
continue
mcp_tools += 1
# Check if it already has scope decorator
if has_scope_decorator(node.decorator_list):
already_has_scope += 1
continue
# Classify operation
scope = classify_operation(node.name)
if scope:
tools_to_update.append((node.name, node.lineno, scope))
else:
cannot_classify += 1
if verbose:
print(f" ⚠️ Cannot classify: {node.name}")
if verbose:
print(
f" Debug: total_functions={total_functions}, mcp_tools={mcp_tools}, already_has_scope={already_has_scope}, cannot_classify={cannot_classify}"
)
return tools_to_update
def add_decorator_to_file(
file_path: Path, dry_run: bool = False, verbose: bool = False
) -> int:
"""Add @require_scopes decorators to tools in a file.
Returns:
Number of decorators added
"""
tools = find_tools_needing_decorators(file_path, verbose=verbose)
if not tools:
return 0
print(f"\n📝 {file_path.relative_to(Path.cwd())}")
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line and "require_scopes" in line:
has_import = True
break
elif "from nextcloud_mcp_server.auth import" in line:
import_line_idx = i
# Add import if needed
if not has_import:
if import_line_idx is not None:
# Add require_scopes to existing import
old_line = lines[import_line_idx]
if "(" in old_line:
# Multi-line import
print(
" ⚠️ Multi-line import detected, please add manually: from nextcloud_mcp_server.auth import require_scopes"
)
else:
# Single line import - add require_scopes
lines[import_line_idx] = (
old_line.rstrip().rstrip(")").rstrip() + ", require_scopes)\n"
)
print(" ✓ Added require_scopes to import")
else:
# No auth import exists, add new import
# Find first import line
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
lines.insert(
i, "from nextcloud_mcp_server.auth import require_scopes\n"
)
print(
" ✓ Added import: from nextcloud_mcp_server.auth import require_scopes"
)
break
# Add decorators to tools (in reverse order to preserve line numbers)
for func_name, line_num, scope in reversed(tools):
# Find the @mcp.tool() decorator line
for i in range(line_num - 1, max(0, line_num - 10), -1):
if "@mcp.tool()" in lines[i]:
# Get indentation from @mcp.tool() line
indent = len(lines[i]) - len(lines[i].lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
lines.insert(i + 1, decorator_line)
print(f'{func_name}:{line_num} → @require_scopes("{scope}")')
break
if not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(" 💾 Saved changes")
else:
print(" 🔍 DRY RUN - no changes written")
return len(tools)
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Show debug information",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
added = add_decorator_to_file(
file_path, dry_run=args.dry_run, verbose=args.verbose
)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Simpler script to add @require_scopes decorators using regex.
This script uses regex patterns to find @mcp.tool() decorators and adds
the appropriate @require_scopes decorator based on function name patterns.
Usage:
python scripts/add_scope_decorators_simple.py [--dry-run]
"""
import argparse
import re
from pathlib import Path
# Operation patterns for classification
READ_KEYWORDS = [
"get",
"list",
"search",
"read",
"find",
"fetch",
"retrieve",
"upcoming",
]
WRITE_KEYWORDS = [
"create",
"update",
"delete",
"append",
"modify",
"set",
"add",
"remove",
"edit",
"move",
"copy",
"upload",
"download",
"share",
"unshare",
"bulk",
"manage",
"import",
"reindex",
"archive",
"unarchive",
"reorder",
"assign",
"unassign",
"insert",
"write",
]
def classify_function(func_name: str) -> str | None:
"""Classify a function name as read or write operation."""
func_lower = func_name.lower()
# Check write keywords first (more specific)
for keyword in WRITE_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:write"
# Check read keywords
for keyword in READ_KEYWORDS:
if f"_{keyword}_" in func_lower or func_lower.endswith(f"_{keyword}"):
return "nc:read"
return None
def process_file(file_path: Path, dry_run: bool = False) -> int:
"""Process a single file to add @require_scopes decorators.
Returns:
Number of decorators added
"""
with open(file_path) as f:
lines = f.readlines()
# Check if require_scopes is already imported
has_import = False
import_line_idx = None
for i, line in enumerate(lines):
if "from nextcloud_mcp_server.auth import" in line:
if "require_scopes" in line:
has_import = True
else:
import_line_idx = i
modified = False
decorators_added = 0
# Find all @mcp.tool() decorators
i = 0
while i < len(lines):
line = lines[i]
# Look for @mcp.tool() decorator
if re.match(r"\s*@mcp\.tool\(\)", line):
# Check if next line already has @require_scopes
if i + 1 < len(lines) and "@require_scopes" in lines[i + 1]:
i += 1
continue
# Find the function definition (should be on next line or after other decorators)
func_line_idx = i + 1
while func_line_idx < len(lines) and not lines[
func_line_idx
].strip().startswith("async def"):
func_line_idx += 1
if func_line_idx >= len(lines):
i += 1
continue
# Extract function name
func_match = re.match(r"\s*async def (\w+)\(", lines[func_line_idx])
if not func_match:
i += 1
continue
func_name = func_match.group(1)
scope = classify_function(func_name)
if scope:
# Get indentation from @mcp.tool() line
indent = len(line) - len(line.lstrip())
decorator_line = " " * indent + f'@require_scopes("{scope}")\n'
# Insert after @mcp.tool()
lines.insert(i + 1, decorator_line)
decorators_added += 1
modified = True
print(f'{func_name} → @require_scopes("{scope}")')
else:
print(f" ⚠️ Cannot classify: {func_name}")
i += 1
# Add import if needed and decorators were added
if decorators_added > 0 and not has_import:
if import_line_idx is not None:
# Add to existing import
old_line = lines[import_line_idx]
if old_line.rstrip().endswith(")"):
lines[import_line_idx] = old_line.rstrip()[:-1] + ", require_scopes)\n"
else:
lines[import_line_idx] = old_line.rstrip() + ", require_scopes\n"
print(" ✓ Added require_scopes to existing import")
modified = True
else:
# No auth import exists, add new import after last 'from nextcloud_mcp_server' import
last_nc_import_idx = None
for i, line in enumerate(lines):
if line.startswith("from nextcloud_mcp_server"):
last_nc_import_idx = i
if last_nc_import_idx is not None:
lines.insert(
last_nc_import_idx + 1,
"from nextcloud_mcp_server.auth import require_scopes\n",
)
print(
" ✓ Added new import: from nextcloud_mcp_server.auth import require_scopes"
)
modified = True
else:
print(" ⚠️ Could not find place to add require_scopes import")
# Write changes
if modified and not dry_run:
with open(file_path, "w") as f:
f.writelines(lines)
print(f" 💾 Saved changes to {file_path.name}")
elif dry_run and decorators_added > 0:
print(f" 🔍 DRY RUN - would add {decorators_added} decorators")
return decorators_added
def main():
parser = argparse.ArgumentParser(
description="Add @require_scopes decorators to MCP tools"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without modifying files",
)
parser.add_argument(
"--file",
type=Path,
help="Process a single file instead of all server modules",
)
args = parser.parse_args()
server_dir = Path(__file__).parent.parent / "nextcloud_mcp_server" / "server"
if args.file:
files = [args.file]
else:
files = sorted(server_dir.glob("*.py"))
files = [f for f in files if f.name != "__init__.py"]
print("🔍 Scanning for tools needing scope decorators...")
print(
f" {'DRY RUN MODE - No changes will be made' if args.dry_run else 'LIVE MODE - Files will be modified'}"
)
total_added = 0
for file_path in files:
file_path = file_path.resolve() # Convert to absolute path
try:
display_path = file_path.relative_to(Path.cwd())
except ValueError:
display_path = file_path.name
print(f"\n📝 {display_path}")
added = process_file(file_path, dry_run=args.dry_run)
total_added += added
print(f"\n{'📊 Summary (dry run)' if args.dry_run else '✅ Complete'}")
print(f" Total decorators added: {total_added}")
if args.dry_run and total_added > 0:
print("\n💡 Run without --dry-run to apply changes")
if __name__ == "__main__":
main()
+846 -54
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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)
+5 -5
View File
@@ -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",
)
+1 -1
View File
@@ -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.
+208
View File
@@ -0,0 +1,208 @@
"""
Test JWT token structure and scope support.
This test obtains a JWT token via OAuth and examines its structure.
"""
import base64
import json
import pytest
def decode_jwt_without_verification(token: str) -> dict:
"""
Decode JWT token without signature verification (for inspection only).
Returns:
Dict with header and payload
"""
parts = token.split(".")
if len(parts) != 3:
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
# Decode header
header = json.loads(
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
)
# Decode payload
payload = json.loads(
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
)
return {
"header": header,
"payload": payload,
}
@pytest.mark.integration
async def test_jwt_token_structure_with_custom_client():
"""
Test that we can create a JWT-enabled OAuth client and examine the token structure.
This test manually configures a JWT client and obtains a token.
"""
import os
import httpx
# This test requires manual setup of a JWT client
# Skip if not configured
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
if not client_id:
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Fetch discovery
async with httpx.AsyncClient() as client:
discovery_response = await client.get(
f"{nextcloud_host}/.well-known/openid-configuration"
)
discovery_response.raise_for_status()
discovery = discovery_response.json()
_token_endpoint = discovery["token_endpoint"]
# For this test, we'll use client credentials grant if supported
# Otherwise, skip this test
pytest.skip(
"JWT token test requires OAuth flow - use manual testing script instead"
)
@pytest.mark.integration
async def test_opaque_token_vs_jwt_comparison():
"""
Compare opaque tokens vs JWT tokens to understand the differences.
This is a documentation test that explains the findings.
"""
# This test documents our findings about JWT vs opaque tokens
# Based on manual testing with the test script
findings = {
"oidc_app_capabilities": {
"supports_jwt_tokens": True,
"supports_opaque_tokens": True,
"configuration_method": "per-client via token_type field",
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
},
"dynamic_registration": {
"sets_allowed_scopes": False,
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
},
"jwt_token_structure": {
"header": {
"typ": "at+JWT", # RFC 9068 access token type
"alg": "RS256", # Signature algorithm
},
"payload_claims": {
"iss": "issuer URL",
"sub": "user ID",
"aud": "client ID",
"exp": "expiration timestamp",
"iat": "issued at timestamp",
"scope": "space-separated scope string (THIS IS THE KEY!)",
"client_id": "client identifier",
"jti": "JWT ID",
# Optional based on scopes:
"roles": "if roles scope present",
"groups": "if groups scope present",
"email": "if email scope present",
"name": "if profile scope present",
},
"scope_claim": {
"format": "space-separated string",
"example": "openid profile email nc:read nc:write",
"extraction": "payload['scope'].split()",
},
},
"scope_validation": {
"oidc_app": {
"validates": True,
"method": "Intersects requested scopes with allowed_scopes per client",
"location": "LoginRedirectorController.php:251-267",
},
"user_oidc_app": {
"validates_scopes": False,
"validates": ["token expiration", "issuer", "audience (optional)"],
"limitation": "Does NOT extract or validate scopes from JWT",
},
},
"token_size": {
"opaque": "72 characters",
"jwt": "~800-1200 characters (depends on claims)",
"overhead": "JWT is 10-15x larger than opaque tokens",
},
"recommendation": {
"for_mcp_server": "Use JWT tokens with self-validation",
"reasoning": [
"Can extract scopes directly from token payload",
"No additional API call needed",
"Standard approach (RFC 9068)",
"Works with existing oidc app",
],
"alternative": "Implement introspection endpoint in oidc app (future work)",
},
}
# Print findings for documentation
print("\n" + "=" * 80)
print("JWT Token vs Opaque Token Findings")
print("=" * 80)
print(json.dumps(findings, indent=2))
print("=" * 80 + "\n")
# This test always passes - it's for documentation
assert True, "Findings documented"
@pytest.mark.integration
async def test_scope_presence_in_jwt():
"""
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
NOTE: This test documents the expected behavior based on manual testing.
Actual implementation will be tested in integration tests after JWT validation is implemented.
"""
expected_behavior = {
"client_configuration": {
"allowed_scopes": "openid profile email nc:read nc:write",
"token_type": "jwt",
},
"authorization_request": {
"scope": "openid profile email nc:read nc:write",
},
"token_response": {
"access_token": "JWT with scope claim",
},
"jwt_payload": {
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
},
"scope_filtering": {
"description": "oidc app filters requested scopes against allowed_scopes",
"example": {
"requested": "openid profile nc:read nc:write nc:admin",
"allowed": "openid profile email nc:read nc:write",
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
},
},
}
print("\n" + "=" * 80)
print("Expected JWT Scope Behavior")
print("=" * 80)
print(json.dumps(expected_behavior, indent=2))
print("=" * 80 + "\n")
assert True, "Expected behavior documented"
if __name__ == "__main__":
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
pytest.main([__file__, "-v", "-s"])
+246
View File
@@ -0,0 +1,246 @@
"""Integration tests for JWT OAuth authentication.
These tests verify:
1. JWT token authentication works correctly
2. JWT token verification via JWKS
3. Scope information is properly extracted from JWT claims
4. Dynamic tool filtering works with JWT tokens
5. All MCP operations work with JWT authentication
"""
import json
import logging
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client):
"""Test connection to JWT OAuth-enabled MCP server."""
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available")
async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client):
"""Test that JWT token authentication works."""
# Execute a simple read operation
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info(
f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes."
)
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
"""Test that list_tools works with JWT authentication."""
result = await nc_mcp_oauth_jwt_client.list_tools()
# Verify we have tools
assert len(result.tools) > 0
# Verify some expected tools exist
tool_names = [tool.name for tool in result.tools]
assert "nc_notes_get_note" in tool_names
assert "nc_notes_create_note" in tool_names
assert "nc_calendar_list_calendars" in tool_names
assert "nc_webdav_list_directory" in tool_names
logger.info(f"JWT server provides {len(result.tools)} tools")
async def test_jwt_read_operation(nc_mcp_oauth_jwt_client):
"""Test read operation with JWT authentication."""
# List calendars (read operation)
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
assert "calendars" in response_data
assert isinstance(response_data["calendars"], list)
logger.info(
f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars."
)
async def test_jwt_write_operation(nc_mcp_oauth_jwt_client):
"""Test write operation with JWT authentication."""
import uuid
# Create a note (write operation)
note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}"
note_content = "This note was created during JWT authentication testing"
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": note_content,
"category": "Testing",
},
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
# Verify note was created
assert "id" in response_data
assert response_data["title"] == note_title
note_id = response_data["id"]
logger.info(f"Successfully created note {note_id} with JWT authentication")
# Clean up: Delete the note
delete_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}"
logger.info(f"Cleaned up test note {note_id}")
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
"""Test multiple operations with same JWT token to verify token persistence."""
# First operation: Search notes
result1 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result1.isError is False
# Second operation: List calendars
result2 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert result2.isError is False
# Third operation: List directory
result3 = await nc_mcp_oauth_jwt_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert result3.isError is False
logger.info("Successfully executed multiple operations with JWT token")
async def test_jwt_vs_opaque_token_compatibility(
nc_mcp_oauth_client, nc_mcp_oauth_jwt_client
):
"""Verify that both opaque and JWT tokens provide same functionality."""
# Execute same operation on both servers
opaque_result = await nc_mcp_oauth_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
jwt_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
# Both should succeed
assert opaque_result.isError is False
assert jwt_result.isError is False
# Both should have results
opaque_data = json.loads(opaque_result.content[0].text)
jwt_data = json.loads(jwt_result.content[0].text)
assert "results" in opaque_data
assert "results" in jwt_data
# Results should be the same (same user, same notes)
assert len(opaque_data["results"]) == len(jwt_data["results"])
logger.info(
"Verified opaque and JWT tokens provide identical functionality: "
f"{len(opaque_data['results'])} notes accessible from both servers"
)
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
"""Test error handling with JWT authentication."""
# Try to get a non-existent note
result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_get_note", arguments={"note_id": 999999}
)
# Should get an error (note doesn't exist)
assert result.isError is True
logger.info("JWT server correctly handles errors for invalid operations")
async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client):
"""Test that JWT server properly enforces scopes."""
# This test assumes the JWT token has both nc:read and nc:write scopes
# Both read and write operations should succeed
# Read operation
read_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert read_result.isError is False
# Write operation
import uuid
note_title = f"Scope Test {uuid.uuid4().hex[:8]}"
write_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_create_note",
arguments={
"title": note_title,
"content": "Testing scope enforcement",
"category": "Testing",
},
)
assert write_result.isError is False
# Clean up
note_id = json.loads(write_result.content[0].text)["id"]
await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
logger.info("JWT server properly allows operations based on token scopes")
async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client):
"""Test that verifies the automated JWT client creation worked correctly.
This test confirms that:
1. JWT client was auto-created during container initialization
2. MCP server loaded credentials from auto-generated file
3. JWT authentication flow works end-to-end
4. Server uses JWT tokens (not opaque tokens)
"""
# If we can connect and execute tools, the automation worked
result = await nc_mcp_oauth_jwt_client.list_tools()
assert result is not None
assert len(result.tools) > 0
# Execute a tool to verify full OAuth flow
tool_result = await nc_mcp_oauth_jwt_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert tool_result.isError is False
logger.info(
"✅ JWT client automation successful! "
"Auto-generated credentials working correctly."
)
+539
View File
@@ -0,0 +1,539 @@
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
These tests verify:
1. Dynamic tool filtering based on user's token scopes
2. Scope enforcement (403 responses for insufficient scopes)
3. Protected Resource Metadata (PRM) endpoint
4. WWW-Authenticate challenge headers
5. BasicAuth bypass (all tools visible)
"""
import pytest
@pytest.mark.integration
async def test_prm_endpoint():
"""Test that the Protected Resource Metadata endpoint returns correct data."""
import httpx
# Test the PRM endpoint directly
async with httpx.AsyncClient() as client:
response = await client.get(
"http://localhost:8001/.well-known/oauth-protected-resource"
)
assert response.status_code == 200
prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8001"
assert "nc:read" in prm_data["scopes_supported"]
assert "nc:write" in prm_data["scopes_supported"]
assert "http://localhost:8080" in prm_data["authorization_servers"]
assert "header" in prm_data["bearer_methods_supported"]
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
@pytest.mark.integration
async def test_basicauth_shows_all_tools(nc_mcp_client):
"""Test that BasicAuth mode shows all tools (no filtering)."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# List all tools
tools_response = await nc_mcp_client.list_tools()
# BasicAuth should see all tools
tool_names = [tool.name for tool in tools_response.tools]
# Should see both read and write tools
assert "nc_notes_get_note" in tool_names # read tool
assert "nc_notes_create_note" in tool_names # write tool
assert "nc_calendar_list_calendars" in tool_names # read tool
assert "nc_calendar_create_event" in tool_names # write tool
# Should have all 90+ tools
assert len(tool_names) >= 90
@pytest.mark.integration
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
"""Test that a token with only nc:read scope filters out write tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:read" scope
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Read-only token sees {len(tool_names)} tools")
# Verify read tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
# Verify write tools are NOT present
write_tools_should_be_filtered = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in write_tools_should_be_filtered:
assert tool not in tool_names, (
f"Write tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, "
f"write tools hidden"
)
@pytest.mark.integration
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
"""Test that a token with only nc:write scope filters out read tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has only "nc:write" scope
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Write-only token sees {len(tool_names)} tools")
# Verify write tools are present
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
# Verify read tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in read_tools_should_be_filtered:
assert tool not in tool_names, (
f"Read tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, "
f"read tools hidden"
)
@pytest.mark.integration
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
"""Test that a token with both nc:read and nc:write scopes can see all tools."""
import logging
logger = logging.getLogger(__name__)
# Connect with token that has both "nc:read" and "nc:write" scopes
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Full access token sees {len(tool_names)} tools")
# Verify both read and write tools are present
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_webdav_read_file",
]
expected_write_tools = [
"nc_notes_create_note",
"nc_calendar_create_event",
"nc_webdav_write_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all 90+ tools (both read and write)
assert len(tool_names) >= 90
logger.info(
f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)"
)
@pytest.mark.integration
async def test_scope_helper_functions():
"""Test the scope authorization helper functions."""
from nextcloud_mcp_server.auth import get_required_scopes, has_required_scopes
# Create a mock function with scope requirements
async def mock_read_tool():
pass
async def mock_write_tool():
pass
async def mock_no_scope_tool():
pass
# Add scope metadata
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
# Test get_required_scopes
assert get_required_scopes(mock_read_tool) == ["nc:read"]
assert get_required_scopes(mock_write_tool) == ["nc:write"]
assert get_required_scopes(mock_no_scope_tool) == []
# Test has_required_scopes
read_only_scopes = {"nc:read"}
full_scopes = {"nc:read", "nc:write"}
no_scopes = set()
# User with only read scope
assert has_required_scopes(mock_read_tool, read_only_scopes) is True
assert has_required_scopes(mock_write_tool, read_only_scopes) is False
assert has_required_scopes(mock_no_scope_tool, read_only_scopes) is True
# User with full scopes
assert has_required_scopes(mock_read_tool, full_scopes) is True
assert has_required_scopes(mock_write_tool, full_scopes) is True
assert has_required_scopes(mock_no_scope_tool, full_scopes) is True
# User with no scopes
assert has_required_scopes(mock_read_tool, no_scopes) is False
assert has_required_scopes(mock_write_tool, no_scopes) is False
assert has_required_scopes(mock_no_scope_tool, no_scopes) is True
@pytest.mark.integration
async def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator properly stores metadata."""
from nextcloud_mcp_server.auth import require_scopes
@require_scopes("nc:read", "nc:write")
async def test_function():
pass
# Check that metadata was stored
assert hasattr(test_function, "_required_scopes")
assert test_function._required_scopes == ["nc:read", "nc:write"]
@pytest.mark.integration
async def test_tools_have_scope_decorators(nc_mcp_client):
"""Test that MCP tools have scope requirements defined."""
# Note: Don't use 'async with' for session-scoped fixtures
# The fixture itself manages the session lifecycle
# We can at least verify that some expected tools exist
tools_response = await nc_mcp_client.list_tools()
tool_names = [tool.name for tool in tools_response.tools]
# Verify expected read tools exist
expected_read_tools = [
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_calendar_list_calendars",
"nc_calendar_get_event",
"nc_contacts_list_contacts",
"nc_webdav_list_directory",
"nc_webdav_read_file",
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify expected write tools exist
expected_write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_delete_note",
"nc_calendar_create_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_contacts_create_contact",
"nc_webdav_write_file",
"nc_webdav_create_directory",
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
@pytest.mark.integration
async def test_scope_classification():
"""Test that our scope classification correctly identifies read vs write operations."""
from scripts.add_scope_decorators_simple import classify_function
# Test read operations
assert classify_function("nc_notes_get_note") == "nc:read"
assert classify_function("nc_notes_search_notes") == "nc:read"
assert classify_function("nc_calendar_list_events") == "nc:read"
assert classify_function("nc_webdav_read_file") == "nc:read"
assert classify_function("nc_calendar_find_availability") == "nc:read"
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
# Test write operations
assert classify_function("nc_notes_create_note") == "nc:write"
assert classify_function("nc_notes_update_note") == "nc:write"
assert classify_function("nc_notes_delete_note") == "nc:write"
assert classify_function("nc_notes_append_content") == "nc:write"
assert classify_function("nc_calendar_create_event") == "nc:write"
assert classify_function("nc_calendar_update_event") == "nc:write"
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
assert classify_function("nc_webdav_write_file") == "nc:write"
assert classify_function("nc_webdav_move_resource") == "nc:write"
assert classify_function("nc_contacts_create_contact") == "nc:write"
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
assert classify_function("nc_tables_insert_row") == "nc:write"
assert classify_function("deck_archive_card") == "nc:write"
assert classify_function("deck_assign_label_to_card") == "nc:write"
@pytest.mark.integration
async def test_all_tools_classified():
"""Verify that all tools can be properly classified as read or write."""
from scripts.add_scope_decorators_simple import classify_function
# List of all tool names (extracted from our implementation)
all_tools = [
# Calendar tools
"nc_calendar_list_calendars",
"nc_calendar_create_event",
"nc_calendar_list_events",
"nc_calendar_get_event",
"nc_calendar_update_event",
"nc_calendar_delete_event",
"nc_calendar_create_meeting",
"nc_calendar_get_upcoming_events",
"nc_calendar_find_availability",
"nc_calendar_bulk_operations",
"nc_calendar_manage_calendar",
"nc_calendar_list_todos",
"nc_calendar_create_todo",
"nc_calendar_update_todo",
"nc_calendar_delete_todo",
"nc_calendar_search_todos",
# Notes tools
"nc_notes_get_note",
"nc_notes_search_notes",
"nc_notes_create_note",
"nc_notes_update_note",
"nc_notes_append_content",
"nc_notes_delete_note",
"nc_notes_get_attachment",
# Add more as needed...
]
unclassified = []
for tool_name in all_tools:
scope = classify_function(tool_name)
if scope is None:
unclassified.append(tool_name)
# All tools should be classifiable
assert len(unclassified) == 0, f"Unclassified tools: {unclassified}"
@pytest.mark.integration
async def test_scope_metadata_coverage(nc_mcp_client):
"""Test that all tools have scope metadata defined (no undecorated tools)."""
# This test would require access to the actual tool functions to check metadata
# For now, we verify that the expected number of tools exists
# Note: Don't use 'async with' for session-scoped fixtures
tools_response = await nc_mcp_client.list_tools()
# We applied decorators to 90 tools
# In BasicAuth mode, all should be visible
assert len(tools_response.tools) >= 90
@pytest.mark.integration
async def test_jwt_with_no_custom_scopes_returns_zero_tools(
nc_mcp_oauth_client_no_custom_scopes,
):
"""
Test that a JWT token with only OIDC default scopes (no nc:read or nc:write) returns 0 tools.
This tests the security behavior when a user declines to grant custom scopes during consent.
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no nc:read or nc:write.
All tools require at least one custom scope, so they should all be filtered out.
"""
import logging
logger = logging.getLogger(__name__)
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_oauth_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
)
# All tools require nc:read or nc:write, so should be filtered out
assert len(tool_names) == 0, (
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
)
logger.info(
"✅ JWT token without custom scopes correctly returns 0 tools (all filtered out)"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only):
"""
Test JWT with only nc:read scope consented.
Simulates user granting only read permission during OAuth consent.
Expected: Should see read tools but not write tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:read consent sees {len(tool_names)} tools")
# Verify read tools are present
read_tools = ["nc_notes_get_note", "nc_notes_search_notes", "nc_webdav_read_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
# Verify write tools are filtered out
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool not in tool_names, f"Write tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:read consent: {len(tool_names)} read tools visible, write tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only):
"""
Test JWT with only nc:write scope consented.
Simulates user granting only write permission during OAuth consent.
Expected: Should see write tools but not read-only tools.
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with nc:write consent sees {len(tool_names)} tools")
# Verify write tools are present
write_tools = [
"nc_notes_create_note",
"nc_notes_update_note",
"nc_webdav_write_file",
]
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Verify read-only tools are filtered out
read_only_tools = ["nc_notes_get_note", "nc_notes_search_notes"]
for tool in read_only_tools:
assert tool not in tool_names, f"Read-only tool {tool} should be filtered out"
logger.info(
f"✅ JWT with nc:write consent: {len(tool_names)} write tools visible, read-only tools filtered"
)
@pytest.mark.integration
async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access):
"""
Test JWT with both nc:read and nc:write scopes consented.
Simulates user granting both permissions during OAuth consent.
Expected: Should see all 90+ tools (both read and write).
"""
import logging
logger = logging.getLogger(__name__)
result = await nc_mcp_oauth_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"JWT with full consent sees {len(tool_names)} tools")
# Verify both read and write tools are present
read_tools = ["nc_notes_get_note", "nc_webdav_read_file"]
write_tools = ["nc_notes_create_note", "nc_webdav_write_file"]
for tool in read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all tools
assert len(tool_names) >= 90, f"Expected 90+ tools but got {len(tool_names)}"
logger.info(
f"✅ JWT with full consent: {len(tool_names)} tools visible (all read + write)"
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+285
View File
@@ -0,0 +1,285 @@
"""Tests for CLI options using Click's testing utilities."""
import os
import pytest
from click.testing import CliRunner
from nextcloud_mcp_server.app import run
@pytest.fixture
def runner():
"""Create a Click CLI runner."""
return CliRunner()
@pytest.fixture
def clean_env(monkeypatch):
"""Clean environment variables before each test."""
env_vars = [
"NEXTCLOUD_HOST",
"NEXTCLOUD_USERNAME",
"NEXTCLOUD_PASSWORD",
"NEXTCLOUD_OIDC_CLIENT_ID",
"NEXTCLOUD_OIDC_CLIENT_SECRET",
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
"NEXTCLOUD_OIDC_SCOPES",
"NEXTCLOUD_OIDC_TOKEN_TYPE",
"NEXTCLOUD_MCP_SERVER_URL",
"NEXTCLOUD_PUBLIC_ISSUER_URL",
]
for var in env_vars:
monkeypatch.delenv(var, raising=False)
def test_help_message_displays_all_options(runner):
"""Test that help message includes all new CLI options."""
result = runner.invoke(run, ["--help"])
assert result.exit_code == 0
# Check for new options
assert "--nextcloud-host" in result.output
assert "--nextcloud-username" in result.output
assert "--nextcloud-password" in result.output
assert "--oauth-scopes" in result.output
assert "--oauth-token-type" in result.output
assert "--public-issuer-url" in result.output
# Check for existing options
assert "--oauth-client-id" in result.output
assert "--oauth-client-secret" in result.output
assert "--mcp-server-url" in result.output
def test_token_type_accepts_valid_values(runner, clean_env):
"""Test that --oauth-token-type accepts bearer and jwt (case insensitive)."""
# Test lowercase bearer
result = runner.invoke(run, ["--oauth-token-type", "bearer", "--help"])
assert result.exit_code == 0
# Test lowercase jwt
result = runner.invoke(run, ["--oauth-token-type", "jwt", "--help"])
assert result.exit_code == 0
# Test uppercase (should work with case_sensitive=False)
result = runner.invoke(run, ["--oauth-token-type", "Bearer", "--help"])
assert result.exit_code == 0
result = runner.invoke(run, ["--oauth-token-type", "JWT", "--help"])
assert result.exit_code == 0
def test_token_type_rejects_invalid_values(runner, clean_env):
"""Test that --oauth-token-type rejects invalid values."""
result = runner.invoke(run, ["--oauth-token-type", "invalid"])
assert result.exit_code != 0
assert "Invalid value" in result.output
def test_cli_options_set_environment_variables(runner, clean_env, monkeypatch):
"""Test that CLI options set environment variables correctly."""
# We need to mock the actual server startup to avoid connection errors
# Store the env vars that get set
captured_env = {}
def mock_get_app(*args, **kwargs):
# Capture environment variables after they're set by CLI
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
}
)
# Raise an exception to stop execution before uvicorn.run
raise SystemExit(0)
# Patch get_app to capture env vars
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://test.example.com",
"--nextcloud-username",
"testuser",
"--nextcloud-password",
"testpass",
"--oauth-scopes",
"openid nc:read",
"--oauth-token-type",
"jwt",
"--public-issuer-url",
"https://public.example.com",
"--mcp-server-url",
"http://test:8000",
],
)
# Verify environment variables were set
assert captured_env["NEXTCLOUD_HOST"] == "https://test.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "testuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "testpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:read"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public.example.com"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://test:8000"
def test_cli_options_override_environment_variables(runner, monkeypatch):
"""Test that CLI options override environment variables."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "bearer")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Provide CLI options that should override env vars
_ = runner.invoke(
run,
[
"--nextcloud-host",
"https://from-cli.example.com",
"--nextcloud-username",
"cliuser",
"--oauth-scopes",
"openid nc:write",
"--oauth-token-type",
"jwt",
],
)
# Verify CLI options overrode env vars
assert captured_env["NEXTCLOUD_HOST"] == "https://from-cli.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "cliuser"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid nc:write"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
def test_environment_variables_used_when_cli_not_provided(runner, monkeypatch):
"""Test that environment variables are used when CLI options not provided."""
# Set environment variables
monkeypatch.setenv("NEXTCLOUD_HOST", "https://from-env.example.com")
monkeypatch.setenv("NEXTCLOUD_USERNAME", "envuser")
monkeypatch.setenv("NEXTCLOUD_PASSWORD", "envpass")
monkeypatch.setenv("NEXTCLOUD_OIDC_SCOPES", "openid email")
monkeypatch.setenv("NEXTCLOUD_OIDC_TOKEN_TYPE", "jwt")
monkeypatch.setenv("NEXTCLOUD_PUBLIC_ISSUER_URL", "https://public-env.example.com")
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_HOST": os.environ.get("NEXTCLOUD_HOST"),
"NEXTCLOUD_USERNAME": os.environ.get("NEXTCLOUD_USERNAME"),
"NEXTCLOUD_PASSWORD": os.environ.get("NEXTCLOUD_PASSWORD"),
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_PUBLIC_ISSUER_URL": os.environ.get(
"NEXTCLOUD_PUBLIC_ISSUER_URL"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Don't provide any CLI options - should use env vars
_ = runner.invoke(run, [])
# Verify env vars were used
assert captured_env["NEXTCLOUD_HOST"] == "https://from-env.example.com"
assert captured_env["NEXTCLOUD_USERNAME"] == "envuser"
assert captured_env["NEXTCLOUD_PASSWORD"] == "envpass"
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid email"
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "jwt"
assert (
captured_env["NEXTCLOUD_PUBLIC_ISSUER_URL"] == "https://public-env.example.com"
)
def test_default_values(runner, clean_env, monkeypatch):
"""Test that default values are used when neither CLI nor env vars provided."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env.update(
{
"NEXTCLOUD_OIDC_SCOPES": os.environ.get("NEXTCLOUD_OIDC_SCOPES"),
"NEXTCLOUD_OIDC_TOKEN_TYPE": os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
),
}
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Don't provide CLI options or env vars - should use defaults
_ = runner.invoke(run, [])
# Verify default values
assert (
captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid profile email nc:read nc:write"
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
assert (
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
)
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
"""Test that token type is normalized correctly regardless of input case."""
captured_env = {}
def mock_get_app(*args, **kwargs):
captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] = os.environ.get(
"NEXTCLOUD_OIDC_TOKEN_TYPE"
)
raise SystemExit(0)
monkeypatch.setattr("nextcloud_mcp_server.app.get_app", mock_get_app)
# Test uppercase JWT
runner.invoke(run, ["--oauth-token-type", "JWT"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["JWT", "jwt"]
# Test mixed case Bearer
captured_env.clear()
runner.invoke(run, ["--oauth-token-type", "Bearer"])
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] in ["Bearer", "bearer"]
Vendored Submodule
+1
Submodule third_party/oidc added at f7f80b72d5
Generated
+158 -1
View File
@@ -72,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"
@@ -281,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"
@@ -799,7 +931,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.17.0"
version = "0.17.1"
source = { editable = "." }
dependencies = [
{ name = "caldav" },
@@ -809,6 +941,7 @@ dependencies = [
{ name = "mcp", extra = ["cli"] },
{ name = "pillow" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pythonvcard4" },
]
@@ -833,6 +966,7 @@ requires-dist = [
{ name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" },
{ 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" },
]
@@ -1023,6 +1157,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"
@@ -1166,6 +1309,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"