feat: Split read/write scopes into app:read/write scopes
This commit is contained in:
@@ -5,18 +5,37 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
The test suite is organized in layers for fast feedback:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# FAST FEEDBACK (recommended for development)
|
||||||
|
# Unit tests only - ~5 seconds
|
||||||
|
uv run pytest tests/unit/ -v
|
||||||
|
|
||||||
|
# Smoke tests - critical path validation - ~30-60 seconds
|
||||||
|
uv run pytest -m smoke -v
|
||||||
|
|
||||||
|
# INTEGRATION TESTS
|
||||||
|
# Integration tests without OAuth - ~2-3 minutes
|
||||||
|
uv run pytest -m "integration and not oauth" -v
|
||||||
|
|
||||||
|
# Full test suite - ~4-5 minutes
|
||||||
uv run pytest
|
uv run pytest
|
||||||
|
|
||||||
# Run integration tests only
|
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
|
||||||
uv run pytest -m integration
|
uv run pytest -m oauth -v
|
||||||
|
|
||||||
|
# COVERAGE
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
uv run pytest --cov
|
uv run pytest --cov
|
||||||
|
|
||||||
|
# LEGACY COMMANDS (still work)
|
||||||
|
# Run all integration tests
|
||||||
|
uv run pytest -m integration -v
|
||||||
|
|
||||||
# Skip integration tests
|
# Skip integration tests
|
||||||
uv run pytest -m "not integration"
|
uv run pytest -m "not integration" -v
|
||||||
```
|
```
|
||||||
|
|
||||||
### Load Testing
|
### Load Testing
|
||||||
@@ -89,16 +108,18 @@ docker-compose up
|
|||||||
# For basic auth changes (most common) - uses admin credentials
|
# For basic auth changes (most common) - uses admin credentials
|
||||||
docker-compose up --build -d mcp
|
docker-compose up --build -d mcp
|
||||||
|
|
||||||
# For OAuth changes - uses OAuth authentication flow
|
# For OAuth changes - uses OAuth authentication with JWT tokens
|
||||||
docker-compose up --build -d mcp-oauth
|
docker-compose up --build -d mcp-oauth
|
||||||
|
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
docker build -t nextcloud-mcp-server .
|
docker build -t nextcloud-mcp-server .
|
||||||
```
|
```
|
||||||
|
|
||||||
**Important: Two MCP Server Containers**
|
**Important: MCP Server Containers**
|
||||||
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
|
||||||
- **`mcp-oauth`** (port 8001): Uses OAuth authentication. Only use this when working on OAuth-specific features or tests.
|
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
|
||||||
|
- JWT tokens are used for testing (faster validation, scopes embedded in token)
|
||||||
|
- The server can handle both JWT and opaque tokens via the token verifier
|
||||||
|
|
||||||
### Environment Setup
|
### Environment Setup
|
||||||
```bash
|
```bash
|
||||||
@@ -179,9 +200,37 @@ FastMCP serialization issue: raw lists get mangled into dicts with numeric strin
|
|||||||
|
|
||||||
### Testing Structure
|
### Testing Structure
|
||||||
|
|
||||||
- **Integration tests** in `tests/client/` and `tests/server/` - Test real Nextcloud API interactions
|
The test suite follows a layered architecture for fast feedback:
|
||||||
- **Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
|
||||||
- Tests are marked with `@pytest.mark.integration` for selective running
|
```
|
||||||
|
tests/
|
||||||
|
├── unit/ # Fast unit tests (~5s total)
|
||||||
|
│ ├── test_scope_decorator.py
|
||||||
|
│ └── test_response_models.py
|
||||||
|
├── smoke/ # Critical path tests (~30-60s)
|
||||||
|
│ └── test_smoke.py
|
||||||
|
├── integration/
|
||||||
|
│ ├── client/ # Direct API layer tests
|
||||||
|
│ │ ├── notes/
|
||||||
|
│ │ ├── calendar/
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── server/ # MCP tool layer tests
|
||||||
|
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
|
||||||
|
│ │ ├── test_oauth_core.py
|
||||||
|
│ │ ├── test_scope_authorization.py
|
||||||
|
│ │ └── ...
|
||||||
|
│ ├── test_mcp.py
|
||||||
|
│ └── ...
|
||||||
|
└── load/ # Performance tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Markers:**
|
||||||
|
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies
|
||||||
|
- `@pytest.mark.integration` - Integration tests requiring Docker containers
|
||||||
|
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
|
||||||
|
- `@pytest.mark.smoke` - Critical path smoke tests
|
||||||
|
|
||||||
|
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
|
||||||
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
- **Important**: Integration tests run against live Docker containers. After making code changes:
|
||||||
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
|
||||||
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
|
||||||
@@ -226,13 +275,13 @@ OAuth integration tests use **automated Playwright browser automation** to compl
|
|||||||
**Example Commands:**
|
**Example Commands:**
|
||||||
```bash
|
```bash
|
||||||
# Run all OAuth tests with Playwright automation using Firefox
|
# Run all OAuth tests with Playwright automation using Firefox
|
||||||
uv run pytest tests/server/test_oauth*.py --browser firefox -v
|
uv run pytest tests/server/oauth/ --browser firefox -v
|
||||||
|
|
||||||
# Run specific tests with visible browser for debugging
|
# Run specific OAuth test file with visible browser for debugging
|
||||||
uv run pytest tests/server/test_mcp_oauth.py --browser firefox --headed -v
|
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
|
||||||
|
|
||||||
# Run with Chromium (default)
|
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
|
||||||
uv run pytest tests/server/test_oauth*.py -v
|
uv run pytest -m oauth -v
|
||||||
```
|
```
|
||||||
|
|
||||||
**Test Environment:**
|
**Test Environment:**
|
||||||
|
|||||||
@@ -182,13 +182,36 @@ Or connect from:
|
|||||||
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
The server exposes Nextcloud functionality through MCP tools (for actions) and resources (for data browsing).
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
Tools enable AI assistants to perform actions:
|
|
||||||
|
The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes.
|
||||||
|
|
||||||
|
#### Available Tool Categories
|
||||||
|
|
||||||
|
| App | Tools | Read Scope | Write Scope | Operations |
|
||||||
|
|-----|-------|-----------|-------------|------------|
|
||||||
|
| **Notes** | 7 | `mcp:notes:read` | `mcp:notes:write` | Create, read, update, delete, search notes |
|
||||||
|
| **Calendar** | 20+ | `mcp:calendar:read` | `mcp:calendar:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||||
|
| **Contacts** | 8 | `mcp:contacts:read` | `mcp:contacts:write` | Create, read, update, delete contacts and address books |
|
||||||
|
| **Files (WebDAV)** | 12 | `mcp:files:read` | `mcp:files:write` | List, read, upload, delete, move files and folders |
|
||||||
|
| **Deck** | 15 | `mcp:deck:read` | `mcp:deck:write` | Boards, stacks, cards, labels, assignments |
|
||||||
|
| **Cookbook** | 13 | `mcp:cookbook:read` | `mcp:cookbook:write` | Recipes, import from URLs, search, categories |
|
||||||
|
| **Tables** | 5 | `mcp:tables:read` | `mcp:tables:write` | Row operations on Nextcloud Tables |
|
||||||
|
| **Sharing** | 10+ | `mcp:sharing:read` | `mcp:sharing:write` | Create, manage, delete shares |
|
||||||
|
|
||||||
|
**Example Tools:**
|
||||||
- `nc_notes_create_note` - Create a new note
|
- `nc_notes_create_note` - Create a new note
|
||||||
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
- `nc_cookbook_import_recipe` - Import recipes from URLs with schema.org metadata
|
||||||
- `deck_create_card` - Create a Deck card
|
- `deck_create_card` - Create a Deck card
|
||||||
- `nc_calendar_create_event` - Create a calendar event
|
- `nc_calendar_create_event` - Create a calendar event
|
||||||
|
- `nc_calendar_create_todo` - Create a CalDAV task/todo
|
||||||
- `nc_contacts_create_contact` - Create a contact
|
- `nc_contacts_create_contact` - Create a contact
|
||||||
- And many more...
|
- `nc_webdav_upload_file` - Upload a file to Nextcloud
|
||||||
|
- And 80+ more...
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `mcp:notes:read` and `mcp:notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools.
|
||||||
|
>
|
||||||
|
> **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234).
|
||||||
|
|
||||||
### Resources
|
### Resources
|
||||||
Resources provide read-only access to Nextcloud data:
|
Resources provide read-only access to Nextcloud data:
|
||||||
|
|||||||
+3
-23
@@ -66,7 +66,7 @@ services:
|
|||||||
|
|
||||||
mcp-oauth:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- app
|
||||||
@@ -77,34 +77,14 @@ services:
|
|||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||||
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
|
||||||
# Client credentials will be registered and stored in volume on first startup
|
# Client credentials will be registered and stored in volume on first startup
|
||||||
|
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||||
volumes:
|
volumes:
|
||||||
- oauth-client-storage:/app/.oauth
|
- 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:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
oauth-client-storage:
|
oauth-client-storage:
|
||||||
oauth-jwt-client-storage:
|
|
||||||
|
|||||||
+68
-70
@@ -28,7 +28,7 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
|||||||
### Key Features
|
### Key Features
|
||||||
|
|
||||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||||
- ✅ **Custom Scopes** - `nc:read` and `nc:write` for read/write access control
|
- ✅ **Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
|
||||||
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||||
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||||
@@ -38,8 +38,8 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
|||||||
|
|
||||||
| Scope | Description | Tool Count |
|
| Scope | Description | Tool Count |
|
||||||
|-------|-------------|------------|
|
|-------|-------------|------------|
|
||||||
| `nc:read` | Read-only access to Nextcloud data | 36 tools |
|
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
||||||
| `nc:write` | Write access to create/modify/delete data | 54 tools |
|
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
|
||||||
|
|
||||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
|
|||||||
"aud": "client_id",
|
"aud": "client_id",
|
||||||
"exp": 1234567890,
|
"exp": 1234567890,
|
||||||
"iat": 1234567890,
|
"iat": 1234567890,
|
||||||
"scope": "openid profile email nc:read nc:write",
|
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
||||||
"client_id": "...",
|
"client_id": "...",
|
||||||
"jti": "..."
|
"jti": "..."
|
||||||
}
|
}
|
||||||
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
|||||||
|
|
||||||
| Scope | Operations | Examples |
|
| Scope | Operations | Examples |
|
||||||
|-------|------------|----------|
|
|-------|------------|----------|
|
||||||
| `nc:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||||
| `nc:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||||
|
|
||||||
### Standard OIDC Scopes
|
### Standard OIDC Scopes
|
||||||
|
|
||||||
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
|||||||
|
|
||||||
**Full Access:**
|
**Full Access:**
|
||||||
```
|
```
|
||||||
openid profile email nc:read nc:write
|
openid profile email mcp:notes:read mcp:notes:write
|
||||||
```
|
```
|
||||||
|
|
||||||
**Read-Only:**
|
**Read-Only:**
|
||||||
```
|
```
|
||||||
openid profile email nc:read
|
openid profile email mcp:notes:read
|
||||||
```
|
```
|
||||||
|
|
||||||
**No Custom Scopes (OIDC only):**
|
**No Custom Scopes (OIDC only):**
|
||||||
@@ -150,32 +150,32 @@ All 90 MCP tools are decorated with scope requirements:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("mcp:notes:read")
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||||
"""Get a note by ID (requires nc:read scope)"""
|
"""Get a note by ID (requires mcp:notes:read scope)"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("mcp:notes:write")
|
||||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||||
"""Create a note (requires nc:write scope)"""
|
"""Create a note (requires mcp:notes:write scope)"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- ✅ 36 read tools decorated with `@require_scopes("nc:read")`
|
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
||||||
- ✅ 54 write tools decorated with `@require_scopes("nc:write")`
|
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
||||||
- ✅ 90/90 tools covered (100%)
|
- ✅ 90/90 tools covered (100%)
|
||||||
|
|
||||||
### Dynamic Tool Filtering
|
### Dynamic Tool Filtering
|
||||||
|
|
||||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||||
|
|
||||||
**Token with `nc:read` only:**
|
**Token with `mcp:notes:read` only:**
|
||||||
- `list_tools()` returns 36 read-only tools
|
- `list_tools()` returns 36 read-only tools
|
||||||
- Write tools are hidden from the tool list
|
- Write tools are hidden from the tool list
|
||||||
|
|
||||||
**Token with `nc:write` only:**
|
**Token with `mcp:notes:write` only:**
|
||||||
- `list_tools()` returns 54 write-only tools
|
- `list_tools()` returns 54 write-only tools
|
||||||
- Read tools are hidden from the tool list
|
- Read tools are hidden from the tool list
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ The MCP server implements **dynamic tool filtering** - users only see tools they
|
|||||||
- `list_tools()` returns all 90 tools
|
- `list_tools()` returns all 90 tools
|
||||||
|
|
||||||
**Token with no custom scopes:**
|
**Token with no custom scopes:**
|
||||||
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
|
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||||
|
|
||||||
**BasicAuth mode:**
|
**BasicAuth mode:**
|
||||||
- `list_tools()` returns all 90 tools (no filtering)
|
- `list_tools()` returns all 90 tools (no filtering)
|
||||||
@@ -197,7 +197,7 @@ When a tool is called without required scopes, the server returns a `403 Forbidd
|
|||||||
```http
|
```http
|
||||||
HTTP/1.1 403 Forbidden
|
HTTP/1.1 403 Forbidden
|
||||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||||
scope="nc:write",
|
scope="mcp:notes:write",
|
||||||
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,8 +212,8 @@ The server implements RFC 9728's Protected Resource Metadata endpoint:
|
|||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"resource": "http://localhost:8002/mcp",
|
"resource": "http://localhost:8001/mcp",
|
||||||
"scopes_supported": ["nc:read", "nc:write"],
|
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
|
||||||
"authorization_servers": ["http://localhost:8080"],
|
"authorization_servers": ["http://localhost:8080"],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"]
|
"resource_signing_alg_values_supported": ["RS256"]
|
||||||
@@ -228,55 +228,53 @@ This allows OAuth clients to discover supported scopes before requesting authori
|
|||||||
|
|
||||||
### Docker Services
|
### Docker Services
|
||||||
|
|
||||||
The development environment includes three MCP server variants:
|
The development environment includes two MCP server variants:
|
||||||
|
|
||||||
| Service | Port | Auth Type | Token Type | Use Case |
|
| Service | Port | Auth Type | Token Type | Use Case |
|
||||||
|---------|------|-----------|------------|----------|
|
|---------|------|-----------|------------|----------|
|
||||||
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
| `mcp` | 8000 | BasicAuth | N/A | Development, testing |
|
||||||
| `mcp-oauth` | 8001 | OAuth | Opaque | Standard OAuth flows |
|
| `mcp-oauth` | 8001 | OAuth | JWT (configurable) | OAuth testing with JWT tokens |
|
||||||
| `mcp-oauth-jwt` | 8002 | OAuth | JWT | Production, JWT testing |
|
|
||||||
|
|
||||||
### JWT Service Configuration
|
### OAuth Service Configuration
|
||||||
|
|
||||||
The `mcp-oauth-jwt` service uses **Dynamic Client Registration (DCR)** by default:
|
The `mcp-oauth` service uses **Dynamic Client Registration (DCR)** by default and is configured to request JWT tokens:
|
||||||
|
|
||||||
**Default Configuration (DCR):**
|
**Default Configuration (DCR with JWT tokens):**
|
||||||
```yaml
|
```yaml
|
||||||
mcp-oauth-jwt:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8002
|
- 127.0.0.1:8001:8001
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./oauth-storage:/app/.oauth # Optional: persist DCR credentials
|
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
**With Pre-Configured Credentials:**
|
**With Pre-Configured Credentials:**
|
||||||
```yaml
|
```yaml
|
||||||
mcp-oauth-jwt:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8002
|
- 127.0.0.1:8001:8001
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
- NEXTCLOUD_OIDC_CLIENT_ID=<your_client_id> # Skips DCR
|
||||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
- NEXTCLOUD_OIDC_CLIENT_SECRET=<your_client_secret> # Skips DCR
|
||||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Points:**
|
**Key Points:**
|
||||||
- **No credentials needed** - DCR automatically registers the client on first start
|
- **No credentials needed** - DCR automatically registers the client on first start
|
||||||
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
|
||||||
- **JWT tokens** - Set `TOKEN_TYPE=jwt` for better performance
|
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
|
||||||
|
- **Token verifier supports both** - Can handle JWT and opaque tokens
|
||||||
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
@@ -289,7 +287,7 @@ mcp-oauth-jwt:
|
|||||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
| `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_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_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_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
|
||||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||||
|
|
||||||
### Dynamic Client Registration (DCR)
|
### Dynamic Client Registration (DCR)
|
||||||
@@ -323,7 +321,7 @@ DCR automatically configures the client based on environment variables:
|
|||||||
# Minimal DCR configuration (no credentials needed!)
|
# Minimal DCR configuration (no credentials needed!)
|
||||||
export NEXTCLOUD_HOST=http://localhost:8080
|
export NEXTCLOUD_HOST=http://localhost:8080
|
||||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write"
|
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
|
||||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -365,7 +363,7 @@ Manual client creation is **optional** but may be preferred when:
|
|||||||
```bash
|
```bash
|
||||||
docker compose exec app php occ oidc:create \
|
docker compose exec app php occ oidc:create \
|
||||||
--token_type=jwt \
|
--token_type=jwt \
|
||||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||||
"Nextcloud MCP Server" \
|
"Nextcloud MCP Server" \
|
||||||
"http://localhost:8000/oauth/callback"
|
"http://localhost:8000/oauth/callback"
|
||||||
```
|
```
|
||||||
@@ -376,7 +374,7 @@ docker compose exec app php occ oidc:create \
|
|||||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||||
"token_type": "jwt",
|
"token_type": "jwt",
|
||||||
"allowed_scopes": "openid profile email nc:read nc:write"
|
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -409,7 +407,7 @@ When credentials are provided via environment variables or storage file, **DCR i
|
|||||||
│ │
|
│ │
|
||||||
│ JWT Access Token │
|
│ JWT Access Token │
|
||||||
│ { │
|
│ { │
|
||||||
│ "scope": "openid nc:read nc:write" │
|
│ "scope": "openid mcp:notes:read mcp:notes:write" │
|
||||||
│ ... │
|
│ ... │
|
||||||
│ } │
|
│ } │
|
||||||
│ │
|
│ │
|
||||||
@@ -466,7 +464,7 @@ When credentials are provided via environment variables or storage file, **DCR i
|
|||||||
|
|
||||||
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||||
- `GET /.well-known/oauth-protected-resource/mcp`
|
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||||
- Advertises `["nc:read", "nc:write"]`
|
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
||||||
- RFC 9728 compliant
|
- RFC 9728 compliant
|
||||||
|
|
||||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||||
@@ -502,7 +500,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
|
|||||||
│ ├─ Authenticate with client credentials
|
│ ├─ Authenticate with client credentials
|
||||||
│ ├─ Response contains:
|
│ ├─ Response contains:
|
||||||
│ │ • active: true/false
|
│ │ • active: true/false
|
||||||
│ │ • scope: "openid nc:read nc:write"
|
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
||||||
│ │ • sub, exp, iat, client_id
|
│ │ • sub, exp, iat, client_id
|
||||||
│ ├─ Extract scopes from response
|
│ ├─ Extract scopes from response
|
||||||
│ └─ Success: Return AccessToken
|
│ └─ Success: Return AccessToken
|
||||||
@@ -556,7 +554,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||||
**Expected:** 0 tools returned (all require `nc:read` or `nc:write`)
|
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||||
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||||
|
|
||||||
#### 2. Read-Only Access (36 tools)
|
#### 2. Read-Only Access (36 tools)
|
||||||
@@ -564,7 +562,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
|||||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||||
```
|
```
|
||||||
|
|
||||||
**Scenario:** JWT token with `nc:read` only
|
**Scenario:** JWT token with `mcp:notes:read` only
|
||||||
**Expected:** 36 read-only tools visible, write tools hidden
|
**Expected:** 36 read-only tools visible, write tools hidden
|
||||||
**Verifies:** Read tools accessible, write tools filtered out
|
**Verifies:** Read tools accessible, write tools filtered out
|
||||||
|
|
||||||
@@ -573,7 +571,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
|||||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||||
```
|
```
|
||||||
|
|
||||||
**Scenario:** JWT token with `nc:write` only
|
**Scenario:** JWT token with `mcp:notes:write` only
|
||||||
**Expected:** 54 write tools visible, read tools hidden
|
**Expected:** 54 write tools visible, read tools hidden
|
||||||
**Verifies:** Write tools accessible, read tools filtered out
|
**Verifies:** Write tools accessible, read tools filtered out
|
||||||
|
|
||||||
@@ -582,21 +580,21 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
|||||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
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`
|
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
|
||||||
**Expected:** All 90 tools visible
|
**Expected:** All 90 tools visible
|
||||||
**Verifies:** Full access when user grants all custom scopes
|
**Verifies:** Full access when user grants all custom scopes
|
||||||
|
|
||||||
### Test Fixtures
|
### Test Fixtures
|
||||||
|
|
||||||
**OAuth Client Fixtures:**
|
**OAuth Client Fixtures:**
|
||||||
- `read_only_oauth_client_credentials` - Client with `nc:read` only
|
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
||||||
- `write_only_oauth_client_credentials` - Client with `nc:write` only
|
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
||||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||||
|
|
||||||
**Token Fixtures:**
|
**Token Fixtures:**
|
||||||
- `playwright_oauth_token_read_only` - Obtains token with `nc:read`
|
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
||||||
- `playwright_oauth_token_write_only` - Obtains token with `nc:write`
|
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
|
||||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||||
|
|
||||||
@@ -684,26 +682,26 @@ docker compose exec app php occ oidc:list
|
|||||||
# If empty, recreate client with --allowed_scopes
|
# If empty, recreate client with --allowed_scopes
|
||||||
docker compose exec app php occ oidc:create \
|
docker compose exec app php occ oidc:create \
|
||||||
--token_type=jwt \
|
--token_type=jwt \
|
||||||
--allowed_scopes="openid profile email nc:read nc:write" \
|
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||||
"Client Name" \
|
"Client Name" \
|
||||||
"http://callback/url"
|
"http://callback/url"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issue: All Tools Visible Despite Read-Only Token
|
### Issue: All Tools Visible Despite Read-Only Token
|
||||||
|
|
||||||
**Symptom:** User with `nc:read` token can see all 90 tools including write tools
|
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
|
||||||
|
|
||||||
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||||
|
|
||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
# Verify OAuth mode is active
|
# Verify OAuth mode is active
|
||||||
docker compose logs mcp-oauth-jwt | grep "OAuth mode"
|
docker compose logs mcp-oauth | grep "OAuth mode"
|
||||||
|
|
||||||
# Should see: "Running in OAuth mode"
|
# Should see: "Running in OAuth mode"
|
||||||
|
|
||||||
# If not, check environment variables:
|
# If not, check environment variables:
|
||||||
docker compose exec mcp-oauth-jwt env | grep NEXTCLOUD_OIDC
|
docker compose exec mcp-oauth env | grep NEXTCLOUD_OIDC
|
||||||
|
|
||||||
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
# Ensure no NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD set
|
||||||
```
|
```
|
||||||
@@ -719,7 +717,7 @@ DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provide
|
|||||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
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;"
|
-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")
|
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
|
||||||
```
|
```
|
||||||
|
|
||||||
**If scopes are missing:**
|
**If scopes are missing:**
|
||||||
@@ -752,12 +750,12 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE=JWT
|
|||||||
**Solution:**
|
**Solution:**
|
||||||
```bash
|
```bash
|
||||||
# Check server logs for OAuth mode
|
# Check server logs for OAuth mode
|
||||||
docker compose logs mcp-oauth-jwt | grep "WWW-Authenticate scope challenges enabled"
|
docker compose logs mcp-oauth | grep "WWW-Authenticate scope challenges enabled"
|
||||||
|
|
||||||
# Should see this during startup
|
# Should see this during startup
|
||||||
|
|
||||||
# Check exception handling
|
# Check exception handling
|
||||||
docker compose logs mcp-oauth-jwt | grep "InsufficientScopeError"
|
docker compose logs mcp-oauth | grep "InsufficientScopeError"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debugging Tools
|
### Debugging Tools
|
||||||
@@ -782,10 +780,10 @@ docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
|||||||
**Check server logs:**
|
**Check server logs:**
|
||||||
```bash
|
```bash
|
||||||
# Follow JWT verification logs
|
# Follow JWT verification logs
|
||||||
docker compose logs -f mcp-oauth-jwt | grep -E "JWT|scope|tool"
|
docker compose logs -f mcp-oauth | grep -E "JWT|scope|tool"
|
||||||
|
|
||||||
# Check for issuer mismatches
|
# Check for issuer mismatches
|
||||||
docker compose logs mcp-oauth-jwt | grep -i issuer
|
docker compose logs mcp-oauth | grep -i issuer
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -806,18 +804,18 @@ docker compose logs mcp-oauth-jwt | grep -i issuer
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml (production)
|
# docker-compose.yml (production)
|
||||||
mcp-oauth-jwt:
|
mcp-oauth:
|
||||||
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
image: ghcr.io/yourusername/nextcloud-mcp-server:latest
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
- NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
- NEXTCLOUD_MCP_SERVER_URL=https://mcp.example.com
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||||
- NEXTCLOUD_OIDC_TOKEN_TYPE=jwt
|
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8001:8001"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
@@ -849,16 +847,16 @@ mcp-oauth-jwt:
|
|||||||
```bash
|
```bash
|
||||||
# Success
|
# Success
|
||||||
INFO JWT verified successfully for user: admin
|
INFO JWT verified successfully for user: admin
|
||||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'nc:read', 'nc:write'}
|
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
||||||
|
|
||||||
# Failures
|
# Failures
|
||||||
WARNING JWT issuer validation failed: Invalid issuer
|
WARNING JWT issuer validation failed: Invalid issuer
|
||||||
WARNING Missing required scopes: nc:write
|
WARNING Missing required scopes: mcp:notes:write
|
||||||
```
|
```
|
||||||
|
|
||||||
### Known Limitations
|
### Known Limitations
|
||||||
|
|
||||||
1. **No Fine-Grained Scopes** - Only coarse `nc:read` and `nc:write` (not per-app scopes)
|
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
|
||||||
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||||
|
|
||||||
### Future Enhancements
|
### Future Enhancements
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Start here to identify your issue:
|
|||||||
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
| "OAuth mode requires client credentials OR dynamic registration" | OIDC apps not configured | [Missing OIDC Apps](#missing-or-misconfigured-oidc-apps) |
|
||||||
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
| "PKCE support validation failed" | OIDC app doesn't advertise PKCE | [PKCE Not Advertised](#pkce-not-advertised) |
|
||||||
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
| "Stored client has expired" | Dynamic client expired | [Client Expired](#client-expired) |
|
||||||
|
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
|
||||||
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
|
||||||
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
|
||||||
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
|
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
|
||||||
@@ -407,6 +408,94 @@ http://localhost:8000/oauth/callback
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Limited Scopes - Only Seeing Notes Tools
|
||||||
|
|
||||||
|
**Symptoms**:
|
||||||
|
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||||
|
- Only Notes tools are available (7 tools instead of 90+)
|
||||||
|
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
|
||||||
|
|
||||||
|
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
|
||||||
|
|
||||||
|
**Diagnosis**:
|
||||||
|
|
||||||
|
Check what scopes the client has been granted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View registered clients and their allowed scopes
|
||||||
|
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, allowed_scopes}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for the client's `allowed_scopes` field. If it's empty or only contains notes scopes, that's the issue.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
|
||||||
|
**Option 1: Delete Client and Reconnect** (Recommended for MCP clients)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find the client ID
|
||||||
|
php occ oidc:list | jq '.[] | select(.name | contains("Claude Code")) | {name, client_id}'
|
||||||
|
|
||||||
|
# Delete the client
|
||||||
|
php occ oidc:delete <client_id>
|
||||||
|
|
||||||
|
# Reconnect from Claude Code
|
||||||
|
# This will trigger a new OAuth flow where you can grant all scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
When reconnecting, you'll see a consent screen listing all available scopes. Make sure to approve all the scopes you want the client to access.
|
||||||
|
|
||||||
|
**Option 2: Update Client Scopes via CLI**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update allowed scopes for an existing client
|
||||||
|
php occ oidc:update <client_id> \
|
||||||
|
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
|
||||||
|
|
||||||
|
# User will need to reconnect to get new token with updated scopes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify Available Scopes**:
|
||||||
|
|
||||||
|
Check what scopes the MCP server advertises:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_supported'
|
||||||
|
|
||||||
|
# Should show all 16 scope categories:
|
||||||
|
# - openid
|
||||||
|
# - mcp:notes:read, mcp:notes:write
|
||||||
|
# - mcp:calendar:read, mcp:calendar:write
|
||||||
|
# - mcp:contacts:read, mcp:contacts:write
|
||||||
|
# - mcp:cookbook:read, mcp:cookbook:write
|
||||||
|
# - mcp:deck:read, mcp:deck:write
|
||||||
|
# - mcp:tables:read, mcp:tables:write
|
||||||
|
# - mcp:files:read, mcp:files:write
|
||||||
|
# - mcp:sharing:read, mcp:sharing:write
|
||||||
|
```
|
||||||
|
|
||||||
|
**Understanding Scope Filtering**:
|
||||||
|
|
||||||
|
The MCP server dynamically filters tools based on the scopes in your access token:
|
||||||
|
- Check server logs for: `✂️ JWT scope filtering: X/90 tools available for scopes: {...}`
|
||||||
|
- This shows how many tools are visible vs total available
|
||||||
|
- Each tool requires specific scopes (read and/or write)
|
||||||
|
|
||||||
|
**Available Scope Categories**:
|
||||||
|
|
||||||
|
| Scope Prefix | Nextcloud App | Read Operations | Write Operations |
|
||||||
|
|--------------|---------------|-----------------|------------------|
|
||||||
|
| `mcp:notes:*` | Notes | Get, search, list | Create, update, delete, append |
|
||||||
|
| `mcp:calendar:*` | Calendar (CalDAV) | Get events, todos, calendars | Create/update/delete events, todos |
|
||||||
|
| `mcp:contacts:*` | Contacts (CardDAV) | Get contacts, address books | Create/update/delete contacts |
|
||||||
|
| `mcp:cookbook:*` | Cookbook | Get recipes, search | Create/update recipes |
|
||||||
|
| `mcp:deck:*` | Deck | Get boards, cards | Create/update boards, cards |
|
||||||
|
| `mcp:tables:*` | Tables | Get rows, tables | Create/update/delete rows |
|
||||||
|
| `mcp:files:*` | Files (WebDAV) | List, read files | Upload, delete, move files |
|
||||||
|
| `mcp:sharing:*` | Sharing | Get shares | Create/update shares |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Switching Authentication Modes
|
## Switching Authentication Modes
|
||||||
|
|
||||||
### From BasicAuth to OAuth
|
### From BasicAuth to OAuth
|
||||||
|
|||||||
@@ -182,15 +182,15 @@ You can test using the MCP OAuth container or manually:
|
|||||||
|
|
||||||
**Option A: Using MCP OAuth container**
|
**Option A: Using MCP OAuth container**
|
||||||
```bash
|
```bash
|
||||||
# The mcp-oauth-jwt container will trigger the OAuth flow
|
# The mcp-oauth container will trigger the OAuth flow
|
||||||
docker compose logs -f mcp-oauth-jwt
|
docker compose logs -f mcp-oauth
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B: Manual browser test**
|
**Option B: Manual browser test**
|
||||||
1. Get client_id from the JWT client JSON
|
1. Get client_id from the JWT client JSON
|
||||||
2. Visit in browser:
|
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
|
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Expected Behavior
|
### 3. Expected Behavior
|
||||||
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
|
|||||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||||
- ✓ Profile information (profile)
|
- ✓ Profile information (profile)
|
||||||
- ✓ Email address (email)
|
- ✓ Email address (email)
|
||||||
- ✓ nc:read (custom scope, shown as-is)
|
- ✓ mcp:notes:read (custom scope, shown as-is)
|
||||||
- ✓ nc:write (custom scope, shown as-is)
|
- ✓ mcp:notes:write (custom scope, shown as-is)
|
||||||
- "Allow" and "Deny" buttons
|
- "Allow" and "Deny" buttons
|
||||||
3. User selects scopes and clicks "Allow"
|
3. User selects scopes and clicks "Allow"
|
||||||
4. Authorization proceeds with selected scopes
|
4. Authorization proceeds with selected scopes
|
||||||
|
|||||||
@@ -192,9 +192,20 @@ async def load_oauth_client_credentials(
|
|||||||
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||||
|
|
||||||
# Get scopes from environment or use defaults
|
# Get scopes from environment or use defaults
|
||||||
scopes = os.getenv(
|
# Default: all app-specific read/write scopes
|
||||||
"NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write"
|
default_scopes = (
|
||||||
|
"openid profile email "
|
||||||
|
"notes:read notes:write "
|
||||||
|
"calendar:read calendar:write "
|
||||||
|
"todo:read todo:write "
|
||||||
|
"contacts:read contacts:write "
|
||||||
|
"cookbook:read cookbook:write "
|
||||||
|
"deck:read deck:write "
|
||||||
|
"tables:read tables:write "
|
||||||
|
"files:read files:write "
|
||||||
|
"sharing:read sharing:write"
|
||||||
)
|
)
|
||||||
|
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||||
|
|
||||||
# Get token type from environment (Bearer or jwt)
|
# Get token type from environment (Bearer or jwt)
|
||||||
@@ -424,9 +435,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
# Asynchronously get the OAuth configuration
|
# Asynchronously get the OAuth configuration
|
||||||
import asyncio
|
import anyio
|
||||||
|
|
||||||
_, token_verifier, auth_settings = asyncio.run(setup_oauth_config())
|
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"Nextcloud MCP",
|
"Nextcloud MCP",
|
||||||
lifespan=app_lifespan_oauth,
|
lifespan=app_lifespan_oauth,
|
||||||
@@ -553,7 +564,27 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"resource": resource_url,
|
"resource": resource_url,
|
||||||
"scopes_supported": ["openid", "nc:read", "nc:write"],
|
"scopes_supported": [
|
||||||
|
"openid",
|
||||||
|
"notes:read",
|
||||||
|
"notes:write",
|
||||||
|
"calendar:read",
|
||||||
|
"calendar:write",
|
||||||
|
"todo:read",
|
||||||
|
"todo:write",
|
||||||
|
"contacts:read",
|
||||||
|
"contacts:write",
|
||||||
|
"cookbook:read",
|
||||||
|
"cookbook:write",
|
||||||
|
"deck:read",
|
||||||
|
"deck:write",
|
||||||
|
"tables:read",
|
||||||
|
"tables:write",
|
||||||
|
"files:read",
|
||||||
|
"files:write",
|
||||||
|
"sharing:read",
|
||||||
|
"sharing:write",
|
||||||
|
],
|
||||||
"authorization_servers": [public_issuer_url],
|
"authorization_servers": [public_issuer_url],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"],
|
"resource_signing_alg_values_supported": ["RS256"],
|
||||||
@@ -704,7 +735,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--oauth-scopes",
|
"--oauth-scopes",
|
||||||
envvar="NEXTCLOUD_OIDC_SCOPES",
|
envvar="NEXTCLOUD_OIDC_SCOPES",
|
||||||
default="openid profile email nc:read nc:write",
|
default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
help="OAuth scopes to request (can also use NEXTCLOUD_OIDC_SCOPES env var)",
|
||||||
)
|
)
|
||||||
@@ -768,7 +799,7 @@ def run(
|
|||||||
|
|
||||||
# OAuth mode with custom scopes and JWT tokens
|
# OAuth mode with custom scopes and JWT tokens
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||||
--oauth-scopes="openid nc:read" --oauth-token-type=jwt
|
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
|
||||||
|
|
||||||
# OAuth with public issuer URL (for Docker/proxy setups)
|
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def require_scopes(*required_scopes: str):
|
|||||||
users who lack the necessary scopes.
|
users who lack the necessary scopes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write")
|
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decorated function that checks scopes before execution
|
Decorated function that checks scopes before execution
|
||||||
@@ -54,15 +54,15 @@ def require_scopes(*required_scopes: str):
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||||
# This tool requires the nc:read scope
|
# This tool requires the notes:read scope
|
||||||
...
|
...
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_create_note(ctx: Context, ...):
|
async def nc_notes_create_note(ctx: Context, ...):
|
||||||
# This tool requires the nc:write scope
|
# This tool requires the notes:write scope
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
async def my_tool(ctx: Context):
|
async def my_tool(ctx: Context):
|
||||||
has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write")
|
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||||
if not has_scopes:
|
if not has_scopes:
|
||||||
# Handle missing scopes
|
# Handle missing scopes
|
||||||
...
|
...
|
||||||
@@ -203,11 +203,11 @@ def get_required_scopes(func: Callable) -> list[str]:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@require_scopes("nc:read", "nc:write")
|
@require_scopes("notes:read", "notes:write")
|
||||||
async def my_tool():
|
async def my_tool():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"]
|
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
return getattr(func, "_required_scopes", [])
|
return getattr(func, "_required_scopes", [])
|
||||||
@@ -253,14 +253,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def create_note():
|
async def create_note():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
user_scopes = {"nc:read", "nc:write"}
|
user_scopes = {"notes:read", "notes:write"}
|
||||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||||
|
|
||||||
limited_user_scopes = {"nc:read"}
|
limited_user_scopes = {"notes:read"}
|
||||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_calendar_tools(mcp: FastMCP):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
"""List all available calendars for the user"""
|
"""List all available calendars for the user"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -29,7 +29,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_create_event(
|
async def nc_calendar_create_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
title: str,
|
title: str,
|
||||||
@@ -105,7 +105,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_list_events(
|
async def nc_calendar_list_events(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -207,7 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_get_event(
|
async def nc_calendar_get_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -219,7 +219,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_update_event(
|
async def nc_calendar_update_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -292,7 +292,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_delete_event(
|
async def nc_calendar_delete_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
event_uid: str,
|
event_uid: str,
|
||||||
@@ -303,7 +303,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_create_meeting(
|
async def nc_calendar_create_meeting(
|
||||||
title: str,
|
title: str,
|
||||||
date: str,
|
date: str,
|
||||||
@@ -369,7 +369,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_get_upcoming_events(
|
async def nc_calendar_get_upcoming_events(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
calendar_name: str = "", # Empty = all calendars
|
calendar_name: str = "", # Empty = all calendars
|
||||||
@@ -419,7 +419,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return all_events[:limit]
|
return all_events[:limit]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("calendar:read")
|
||||||
async def nc_calendar_find_availability(
|
async def nc_calendar_find_availability(
|
||||||
duration_minutes: int,
|
duration_minutes: int,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -499,7 +499,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_bulk_operations(
|
async def nc_calendar_bulk_operations(
|
||||||
operation: str, # "update", "delete", "move"
|
operation: str, # "update", "delete", "move"
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -748,7 +748,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("calendar:write")
|
||||||
async def nc_calendar_manage_calendar(
|
async def nc_calendar_manage_calendar(
|
||||||
action: str, # "create", "delete", "update", "list"
|
action: str, # "create", "delete", "update", "list"
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -817,7 +817,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
# ============= Todo/Task Tools =============
|
# ============= Todo/Task Tools =============
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
async def nc_calendar_list_todos(
|
async def nc_calendar_list_todos(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -862,7 +862,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
async def nc_calendar_create_todo(
|
async def nc_calendar_create_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
summary: str,
|
summary: str,
|
||||||
@@ -905,7 +905,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
async def nc_calendar_update_todo(
|
async def nc_calendar_update_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
todo_uid: str,
|
todo_uid: str,
|
||||||
@@ -965,7 +965,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
async def nc_calendar_delete_todo(
|
async def nc_calendar_delete_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
todo_uid: str,
|
todo_uid: str,
|
||||||
@@ -985,7 +985,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
async def nc_calendar_search_todos(
|
async def nc_calendar_search_todos(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("contacts:read")
|
||||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||||
"""List all addressbooks for the user."""
|
"""List all addressbooks for the user."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
return await client.contacts.list_addressbooks()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("contacts:read")
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_create_addressbook(
|
async def nc_contacts_create_addressbook(
|
||||||
ctx: Context, *, name: str, display_name: str
|
ctx: Context, *, name: str, display_name: str
|
||||||
):
|
):
|
||||||
@@ -41,14 +41,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
"""Delete an addressbook."""
|
"""Delete an addressbook."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_addressbook(name=name)
|
return await client.contacts.delete_addressbook(name=name)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_create_contact(
|
async def nc_contacts_create_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||||
):
|
):
|
||||||
@@ -65,14 +65,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
"""Delete a contact."""
|
"""Delete a contact."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("contacts:write")
|
||||||
async def nc_contacts_update_contact(
|
async def nc_contacts_update_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||||
"""Import a recipe from a URL using schema.org metadata.
|
"""Import a recipe from a URL using schema.org metadata.
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||||
"""Get all recipes in the database"""
|
"""Get all recipes in the database"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -153,7 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||||
"""Get a specific recipe by its ID"""
|
"""Get a specific recipe by its ID"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -178,7 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_create_recipe(
|
async def nc_cookbook_create_recipe(
|
||||||
name: str,
|
name: str,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
@@ -257,7 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_update_recipe(
|
async def nc_cookbook_update_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
@@ -346,7 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_delete_recipe(
|
async def nc_cookbook_delete_recipe(
|
||||||
recipe_id: int, ctx: Context
|
recipe_id: int, ctx: Context
|
||||||
) -> DeleteRecipeResponse:
|
) -> DeleteRecipeResponse:
|
||||||
@@ -381,7 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_search_recipes(
|
async def nc_cookbook_search_recipes(
|
||||||
query: str, ctx: Context
|
query: str, ctx: Context
|
||||||
) -> SearchRecipesResponse:
|
) -> SearchRecipesResponse:
|
||||||
@@ -417,7 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||||
"""Get all known categories.
|
"""Get all known categories.
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_get_recipes_in_category(
|
async def nc_cookbook_get_recipes_in_category(
|
||||||
category: str, ctx: Context
|
category: str, ctx: Context
|
||||||
) -> ListRecipesResponse:
|
) -> ListRecipesResponse:
|
||||||
@@ -480,7 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||||
"""Get all known keywords/tags"""
|
"""Get all known keywords/tags"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -505,7 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("cookbook:read")
|
||||||
async def nc_cookbook_get_recipes_with_keywords(
|
async def nc_cookbook_get_recipes_with_keywords(
|
||||||
keywords: list[str], ctx: Context
|
keywords: list[str], ctx: Context
|
||||||
) -> ListRecipesResponse:
|
) -> ListRecipesResponse:
|
||||||
@@ -539,7 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_set_config(
|
async def nc_cookbook_set_config(
|
||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
update_interval: int | None = None,
|
update_interval: int | None = None,
|
||||||
@@ -582,7 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("cookbook:write")
|
||||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||||
"""Trigger a rescan of all recipes into the caching database.
|
"""Trigger a rescan of all recipes into the caching database.
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Read Tools (converted from resources)
|
# Read Tools (converted from resources)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -125,7 +125,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return boards
|
return boards
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
"""Get details of a specific Nextcloud Deck board"""
|
"""Get details of a specific Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -133,7 +133,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return board
|
return board
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -141,7 +141,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
"""Get details of a specific Nextcloud Deck stack"""
|
"""Get details of a specific Nextcloud Deck stack"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -149,7 +149,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stack
|
return stack
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> list[DeckCard]:
|
||||||
@@ -161,7 +161,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_card(
|
async def deck_get_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> DeckCard:
|
) -> DeckCard:
|
||||||
@@ -171,7 +171,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return card
|
return card
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||||
"""Get all labels in a Nextcloud Deck board"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -179,7 +179,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return board.labels
|
return board.labels
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("deck:read")
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
"""Get details of a specific Nextcloud Deck label"""
|
"""Get details of a specific Nextcloud Deck label"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Create/Update/Delete Tools
|
# Create/Update/Delete Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_create_board(
|
async def deck_create_board(
|
||||||
ctx: Context, title: str, color: str
|
ctx: Context, title: str, color: str
|
||||||
) -> CreateBoardResponse:
|
) -> CreateBoardResponse:
|
||||||
@@ -206,7 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
# Stack Tools
|
# Stack Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_create_stack(
|
async def deck_create_stack(
|
||||||
ctx: Context, board_id: int, title: str, order: int
|
ctx: Context, board_id: int, title: str, order: int
|
||||||
) -> CreateStackResponse:
|
) -> CreateStackResponse:
|
||||||
@@ -222,7 +222,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_update_stack(
|
async def deck_update_stack(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -248,7 +248,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_stack(
|
async def deck_delete_stack(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> StackOperationResponse:
|
) -> StackOperationResponse:
|
||||||
@@ -269,7 +269,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card Tools
|
# Card Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_create_card(
|
async def deck_create_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -303,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_update_card(
|
async def deck_update_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -356,7 +356,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_card(
|
async def deck_delete_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -378,7 +378,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_archive_card(
|
async def deck_archive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -400,7 +400,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_unarchive_card(
|
async def deck_unarchive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -422,7 +422,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_reorder_card(
|
async def deck_reorder_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -454,7 +454,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Label Tools
|
# Label Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_create_label(
|
async def deck_create_label(
|
||||||
ctx: Context, board_id: int, title: str, color: str
|
ctx: Context, board_id: int, title: str, color: str
|
||||||
) -> CreateLabelResponse:
|
) -> CreateLabelResponse:
|
||||||
@@ -470,7 +470,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_update_label(
|
async def deck_update_label(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
board_id: int,
|
board_id: int,
|
||||||
@@ -496,7 +496,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_delete_label(
|
async def deck_delete_label(
|
||||||
ctx: Context, board_id: int, label_id: int
|
ctx: Context, board_id: int, label_id: int
|
||||||
) -> LabelOperationResponse:
|
) -> LabelOperationResponse:
|
||||||
@@ -517,7 +517,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card-Label Assignment Tools
|
# Card-Label Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_assign_label_to_card(
|
async def deck_assign_label_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -540,7 +540,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_remove_label_from_card(
|
async def deck_remove_label_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -564,7 +564,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Card-User Assignment Tools
|
# Card-User Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_assign_user_to_card(
|
async def deck_assign_user_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
@@ -587,7 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("deck:write")
|
||||||
async def deck_unassign_user_from_card(
|
async def deck_unassign_user_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
) -> CardOperationResponse:
|
) -> CardOperationResponse:
|
||||||
|
|||||||
@@ -85,11 +85,11 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
title: str, content: str, category: str, ctx: Context
|
title: str, content: str, category: str, ctx: Context
|
||||||
) -> CreateNoteResponse:
|
) -> CreateNoteResponse:
|
||||||
"""Create a new note (requires nc:write scope)"""
|
"""Create a new note (requires notes:write scope)"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.create_note(
|
note_data = await client.notes.create_note(
|
||||||
@@ -131,7 +131,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_update_note(
|
async def nc_notes_update_note(
|
||||||
note_id: int,
|
note_id: int,
|
||||||
etag: str,
|
etag: str,
|
||||||
@@ -140,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
category: str | None,
|
category: str | None,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
) -> UpdateNoteResponse:
|
) -> UpdateNoteResponse:
|
||||||
"""Update an existing note's title, content, or category (requires nc:write scope).
|
"""Update an existing note's title, content, or category (requires notes:write scope).
|
||||||
|
|
||||||
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
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.
|
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||||
@@ -196,7 +196,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
note_id: int, content: str, ctx: Context
|
note_id: int, content: str, ctx: Context
|
||||||
) -> AppendContentResponse:
|
) -> AppendContentResponse:
|
||||||
@@ -246,9 +246,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
"""Search notes by title or content, returning only id, title, and category (requires nc:read scope)."""
|
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
search_results_raw = await client.notes_search_notes(query=query)
|
search_results_raw = await client.notes_search_notes(query=query)
|
||||||
@@ -292,9 +292,9 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
"""Get a specific note by its ID (requires nc:read scope)"""
|
"""Get a specific note by its ID (requires notes:read scope)"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
@@ -321,7 +321,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("notes:read")
|
||||||
async def nc_notes_get_attachment(
|
async def nc_notes_get_attachment(
|
||||||
note_id: int, attachment_filename: str, ctx: Context
|
note_id: int, attachment_filename: str, ctx: Context
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -367,7 +367,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("notes:write")
|
||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
"""Delete a note permanently"""
|
"""Delete a note permanently"""
|
||||||
logger.info("Deleting note %s", note_id)
|
logger.info("Deleting note %s", note_id)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_create(
|
async def nc_share_create(
|
||||||
path: str,
|
path: str,
|
||||||
share_with: str,
|
share_with: str,
|
||||||
@@ -55,7 +55,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||||
"""Delete a share by its ID.
|
"""Delete a share by its ID.
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||||
"""Get information about a specific share.
|
"""Get information about a specific share.
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_list(
|
async def nc_share_list(
|
||||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -113,7 +113,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
return json.dumps(shares, indent=2)
|
return json.dumps(shares, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("sharing:write")
|
||||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||||
"""Update the permissions of an existing share.
|
"""Update the permissions of an existing share.
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_tables_tools(mcp: FastMCP):
|
def configure_tables_tools(mcp: FastMCP):
|
||||||
# Tables tools
|
# Tables tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_list_tables(ctx: Context):
|
async def nc_tables_list_tables(ctx: Context):
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
return await client.tables.list_tables()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
"""Get the schema/structure of a specific table including columns and views"""
|
"""Get the schema/structure of a specific table including columns and views"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
return await client.tables.get_table_schema(table_id)
|
return await client.tables.get_table_schema(table_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("tables:read")
|
||||||
async def nc_tables_read_table(
|
async def nc_tables_read_table(
|
||||||
table_id: int,
|
table_id: int,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -37,7 +37,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||||
"""Insert a new row into a table.
|
"""Insert a new row into a table.
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.create_row(table_id, data)
|
return await client.tables.create_row(table_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||||
"""Update an existing row in a table.
|
"""Update an existing row in a table.
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
return await client.tables.update_row(row_id, data)
|
return await client.tables.update_row(row_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("tables:write")
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
"""Delete a row from a table"""
|
"""Delete a row from a table"""
|
||||||
client = get_client(ctx)
|
client = get_client(ctx)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_webdav_tools(mcp: FastMCP):
|
def configure_webdav_tools(mcp: FastMCP):
|
||||||
# WebDAV file system tools
|
# WebDAV file system tools
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
async def nc_webdav_list_directory(ctx: Context, path: str = ""):
|
||||||
"""List files and directories in the specified NextCloud path.
|
"""List files and directories in the specified NextCloud path.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
return await client.webdav.list_directory(path)
|
return await client.webdav.list_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
"""Read the content of a file from NextCloud.
|
"""Read the content of a file from NextCloud.
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_write_file(
|
async def nc_webdav_write_file(
|
||||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||||
):
|
):
|
||||||
@@ -93,7 +93,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
"""Create a directory in NextCloud.
|
"""Create a directory in NextCloud.
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
return await client.webdav.create_directory(path)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
"""Delete a file or directory in NextCloud.
|
"""Delete a file or directory in NextCloud.
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_move_resource(
|
async def nc_webdav_move_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
):
|
):
|
||||||
@@ -141,7 +141,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:write")
|
@require_scopes("files:write")
|
||||||
async def nc_webdav_copy_resource(
|
async def nc_webdav_copy_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
):
|
):
|
||||||
@@ -161,7 +161,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_search_files(
|
async def nc_webdav_search_files(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
scope: str = "",
|
scope: str = "",
|
||||||
@@ -277,7 +277,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_find_by_name(
|
async def nc_webdav_find_by_name(
|
||||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
) -> SearchFilesResponse:
|
) -> SearchFilesResponse:
|
||||||
@@ -304,7 +304,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_find_by_type(
|
async def nc_webdav_find_by_type(
|
||||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
) -> SearchFilesResponse:
|
) -> SearchFilesResponse:
|
||||||
@@ -331,7 +331,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("nc:read")
|
@require_scopes("files:read")
|
||||||
async def nc_webdav_list_favorites(
|
async def nc_webdav_list_favorites(
|
||||||
ctx: Context, scope: str = "", limit: int | None = None
|
ctx: Context, scope: str = "", limit: int | None = None
|
||||||
) -> SearchFilesResponse:
|
) -> SearchFilesResponse:
|
||||||
|
|||||||
+6
-3
@@ -18,7 +18,7 @@ dependencies = [
|
|||||||
"pydantic>=2.11.4",
|
"pydantic>=2.11.4",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
"caldav",
|
"caldav",
|
||||||
"pyjwt[crypto]>=2.8.0", # JWT validation with RSA support
|
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
@@ -46,8 +46,10 @@ log_cli = 1
|
|||||||
log_cli_level = "ERROR"
|
log_cli_level = "ERROR"
|
||||||
log_level = "ERROR"
|
log_level = "ERROR"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as slow (deselect with '-m \"not slow\"')",
|
"unit: Fast unit tests with mocked dependencies",
|
||||||
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
|
"integration: Integration tests requiring Docker containers",
|
||||||
|
"oauth: OAuth tests requiring Playwright (slowest)",
|
||||||
|
"smoke: Critical path smoke tests for quick validation",
|
||||||
]
|
]
|
||||||
testpaths = [
|
testpaths = [
|
||||||
"tests",
|
"tests",
|
||||||
@@ -85,6 +87,7 @@ dev = [
|
|||||||
"playwright>=1.49.1",
|
"playwright>=1.49.1",
|
||||||
"pytest>=8.3.5",
|
"pytest>=8.3.5",
|
||||||
"pytest-cov>=6.1.1",
|
"pytest-cov>=6.1.1",
|
||||||
|
"pytest-mock>=3.15.1",
|
||||||
"pytest-playwright-asyncio>=0.7.1",
|
"pytest-playwright-asyncio>=0.7.1",
|
||||||
"pytest-timeout>=2.3.1",
|
"pytest-timeout>=2.3.1",
|
||||||
"ruff>=0.11.13",
|
"ruff>=0.11.13",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ async def test_cookbook_update_recipe(nc_client: NextcloudClient):
|
|||||||
assert updated_id == recipe_id
|
assert updated_id == recipe_id
|
||||||
|
|
||||||
# Verify the update
|
# Verify the update
|
||||||
await asyncio.sleep(1) # Allow propagation
|
await anyio.sleep(1) # Allow propagation
|
||||||
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
updated_recipe = await nc_client.cookbook.get_recipe(recipe_id)
|
||||||
assert updated_recipe["description"] == "Updated description"
|
assert updated_recipe["description"] == "Updated description"
|
||||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||||
@@ -227,7 +227,7 @@ async def test_cookbook_search_recipes(nc_client: NextcloudClient):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Allow time for indexing
|
# Allow time for indexing
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# Search for the recipe
|
# Search for the recipe
|
||||||
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
logger.info(f"Searching for recipes with keyword: {unique_keyword}")
|
||||||
@@ -279,7 +279,7 @@ async def test_cookbook_get_recipes_in_category(nc_client: NextcloudClient):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Allow time for indexing
|
# Allow time for indexing
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# Get recipes in this category
|
# Get recipes in this category
|
||||||
logger.info(f"Getting recipes in category: {unique_category}")
|
logger.info(f"Getting recipes in category: {unique_category}")
|
||||||
@@ -335,11 +335,11 @@ async def test_cookbook_get_recipes_with_keywords(nc_client: NextcloudClient):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Allow extra time for indexing
|
# Allow extra time for indexing
|
||||||
await asyncio.sleep(3)
|
await anyio.sleep(3)
|
||||||
|
|
||||||
# Trigger a reindex to ensure the recipe is indexed
|
# Trigger a reindex to ensure the recipe is indexed
|
||||||
await nc_client.cookbook.reindex()
|
await nc_client.cookbook.reindex()
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# Get recipes with this keyword
|
# Get recipes with this keyword
|
||||||
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
logger.info(f"Getting recipes with keyword: {unique_keyword}")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import uuid # Keep uuid if needed for generating unique data within tests
|
import uuid # Keep uuid if needed for generating unique data within tests
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ async def test_notes_api_update(nc_client: NextcloudClient, temporary_note: dict
|
|||||||
assert updated_note["etag"] != original_etag # Etag must change
|
assert updated_note["etag"] != original_etag # Etag must change
|
||||||
|
|
||||||
# Optional: Verify update by reading again
|
# Optional: Verify update by reading again
|
||||||
await asyncio.sleep(1) # Allow potential propagation delay
|
await anyio.sleep(1) # Allow potential propagation delay
|
||||||
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
read_updated_note = await nc_client.notes.get_note(note_id=note_id)
|
||||||
assert read_updated_note["title"] == update_title
|
assert read_updated_note["title"] == update_title
|
||||||
assert read_updated_note["content"] == update_content
|
assert read_updated_note["content"] == update_content
|
||||||
@@ -96,7 +96,7 @@ async def test_notes_api_update_conflict(
|
|||||||
new_etag = first_updated_note["etag"]
|
new_etag = first_updated_note["etag"]
|
||||||
assert new_etag != original_etag
|
assert new_etag != original_etag
|
||||||
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
logger.info(f"Note ID: {note_id} updated, new etag: {new_etag}")
|
||||||
await asyncio.sleep(1)
|
await anyio.sleep(1)
|
||||||
|
|
||||||
# Now attempt update with the *original* etag
|
# Now attempt update with the *original* etag
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -155,7 +155,7 @@ async def test_notes_api_append_content_to_existing_note(
|
|||||||
assert updated_note["content"] == expected_content
|
assert updated_note["content"] == expected_content
|
||||||
|
|
||||||
# Verify by reading the note again
|
# Verify by reading the note again
|
||||||
await asyncio.sleep(1) # Allow potential propagation delay
|
await anyio.sleep(1) # Allow potential propagation delay
|
||||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||||
assert read_note["content"] == expected_content
|
assert read_note["content"] == expected_content
|
||||||
logger.info(f"Successfully appended content to note ID: {note_id}")
|
logger.info(f"Successfully appended content to note ID: {note_id}")
|
||||||
@@ -189,7 +189,7 @@ async def test_notes_api_append_content_to_empty_note(nc_client: NextcloudClient
|
|||||||
assert updated_note["content"] == append_text
|
assert updated_note["content"] == append_text
|
||||||
|
|
||||||
# Verify by reading the note again
|
# Verify by reading the note again
|
||||||
await asyncio.sleep(1)
|
await anyio.sleep(1)
|
||||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||||
assert read_note["content"] == append_text
|
assert read_note["content"] == append_text
|
||||||
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
logger.info(f"Successfully appended content to empty note ID: {note_id}")
|
||||||
@@ -237,7 +237,7 @@ async def test_notes_api_append_content_multiple_times(
|
|||||||
assert updated_note["content"] == expected_content_after_second
|
assert updated_note["content"] == expected_content_after_second
|
||||||
|
|
||||||
# Verify by reading the note again
|
# Verify by reading the note again
|
||||||
await asyncio.sleep(1)
|
await anyio.sleep(1)
|
||||||
read_note = await nc_client.notes.get_note(note_id=note_id)
|
read_note = await nc_client.notes.get_note(note_id=note_id)
|
||||||
assert read_note["content"] == expected_content_after_second
|
assert read_note["content"] == expected_content_after_second
|
||||||
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
logger.info(f"Successfully performed multiple appends to note ID: {note_id}")
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ async def test_tables_create_row(
|
|||||||
assert created_row["tableId"] == table_id
|
assert created_row["tableId"] == table_id
|
||||||
|
|
||||||
# Verify the row was created by reading it back
|
# Verify the row was created by reading it back
|
||||||
await asyncio.sleep(1) # Allow potential propagation delay
|
await anyio.sleep(1) # Allow potential propagation delay
|
||||||
rows = await nc_client.tables.get_table_rows(table_id)
|
rows = await nc_client.tables.get_table_rows(table_id)
|
||||||
created_row_id = created_row["id"]
|
created_row_id = created_row["id"]
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ async def test_tables_update_row(
|
|||||||
assert updated_row["id"] == row_id
|
assert updated_row["id"] == row_id
|
||||||
|
|
||||||
# Verify the row was updated by reading it back
|
# Verify the row was updated by reading it back
|
||||||
await asyncio.sleep(1) # Allow potential propagation delay
|
await anyio.sleep(1) # Allow potential propagation delay
|
||||||
table_id = sample_table_info["table_id"]
|
table_id = sample_table_info["table_id"]
|
||||||
rows = await nc_client.tables.get_table_rows(table_id)
|
rows = await nc_client.tables.get_table_rows(table_id)
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ async def test_tables_delete_row(
|
|||||||
# The delete response might vary, but it should be successful
|
# The delete response might vary, but it should be successful
|
||||||
|
|
||||||
# Verify the row was deleted by trying to find it
|
# Verify the row was deleted by trying to find it
|
||||||
await asyncio.sleep(1) # Allow potential propagation delay
|
await anyio.sleep(1) # Allow potential propagation delay
|
||||||
rows = await nc_client.tables.get_table_rows(table_id)
|
rows = await nc_client.tables.get_table_rows(table_id)
|
||||||
|
|
||||||
# Ensure the deleted row is not in the results
|
# Ensure the deleted row is not in the results
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ logger = logging.getLogger(__name__)
|
|||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_create_and_delete_share(nc_client):
|
async def test_create_and_delete_share(nc_client):
|
||||||
"""Test creating and deleting a file share."""
|
"""Test creating and deleting a file share."""
|
||||||
# Create a test user to share with
|
# Create a test user to share with
|
||||||
@@ -68,7 +67,6 @@ async def test_create_and_delete_share(nc_client):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_update_share_permissions(nc_client):
|
async def test_update_share_permissions(nc_client):
|
||||||
"""Test updating share permissions."""
|
"""Test updating share permissions."""
|
||||||
# Create a test user to share with
|
# Create a test user to share with
|
||||||
@@ -120,7 +118,6 @@ async def test_update_share_permissions(nc_client):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_list_shares(nc_client):
|
async def test_list_shares(nc_client):
|
||||||
"""Test listing all shares."""
|
"""Test listing all shares."""
|
||||||
# Create a test user to share with
|
# Create a test user to share with
|
||||||
|
|||||||
+129
-76
@@ -1,9 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
@@ -14,6 +14,48 @@ from nextcloud_mcp_server.client import NextcloudClient
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default scopes for OAuth testing - all app-specific read/write scopes
|
||||||
|
DEFAULT_FULL_SCOPES = (
|
||||||
|
"openid profile email "
|
||||||
|
"notes:read notes:write "
|
||||||
|
"calendar:read calendar:write "
|
||||||
|
"todo:read todo:write "
|
||||||
|
"contacts:read contacts:write "
|
||||||
|
"cookbook:read cookbook:write "
|
||||||
|
"deck:read deck:write "
|
||||||
|
"tables:read tables:write "
|
||||||
|
"files:read files:write "
|
||||||
|
"sharing:read sharing:write"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion
|
||||||
|
DEFAULT_READ_SCOPES = (
|
||||||
|
"openid profile email "
|
||||||
|
"notes:read "
|
||||||
|
"calendar:read "
|
||||||
|
"todo:read "
|
||||||
|
"contacts:read "
|
||||||
|
"cookbook:read "
|
||||||
|
"deck:read "
|
||||||
|
"tables:read "
|
||||||
|
"files:read "
|
||||||
|
"sharing:read"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion
|
||||||
|
DEFAULT_WRITE_SCOPES = (
|
||||||
|
"openid profile email "
|
||||||
|
"notes:write "
|
||||||
|
"calendar:write "
|
||||||
|
"todo:write "
|
||||||
|
"contacts:write "
|
||||||
|
"cookbook:write "
|
||||||
|
"deck:write "
|
||||||
|
"tables:write "
|
||||||
|
"files:write "
|
||||||
|
"sharing:write"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def anyio_backend():
|
def anyio_backend():
|
||||||
@@ -56,7 +98,7 @@ async def wait_for_nextcloud(
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})"
|
f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(delay)
|
await anyio.sleep(delay)
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Nextcloud server at {host} did not become ready after {max_attempts} attempts"
|
f"Nextcloud server at {host} did not become ready after {max_attempts} attempts"
|
||||||
@@ -191,18 +233,18 @@ async def nc_mcp_oauth_jwt_client(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session for JWT OAuth integration tests.
|
Fixture to create an MCP client session for JWT OAuth integration tests.
|
||||||
Connects to the JWT OAuth-enabled MCP server on port 8002 with OAuth authentication.
|
Connects to the OAuth-enabled MCP server on port 8001 with JWT token authentication.
|
||||||
|
|
||||||
This server uses JWT tokens (RFC 9068) instead of opaque tokens, enabling:
|
Uses JWT tokens (RFC 9068) which provide:
|
||||||
- Token introspection via JWT signature verification
|
- Token validation via JWT signature verification (JWKS)
|
||||||
- Scope information embedded in token claims
|
- Scope information embedded in token claims
|
||||||
- Offline token validation without userinfo endpoint
|
- Faster validation without userinfo endpoint call
|
||||||
|
|
||||||
Uses headless browser automation suitable for CI/CD.
|
Uses headless browser automation suitable for CI/CD.
|
||||||
Uses anyio pytest plugin for proper async fixture handling.
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8002/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_jwt,
|
token=playwright_oauth_token_jwt,
|
||||||
client_name="OAuth JWT MCP (Playwright)",
|
client_name="OAuth JWT MCP (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -215,17 +257,17 @@ async def nc_mcp_oauth_client_read_only(
|
|||||||
playwright_oauth_token_read_only: str,
|
playwright_oauth_token_read_only: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session with only nc:read scope.
|
Fixture to create an MCP client session with only read scopes.
|
||||||
Connects to the JWT OAuth-enabled MCP server on port 8002.
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
This client should only see read tools and should get 403 errors
|
This client should only see read tools and should get 403 errors
|
||||||
when attempting to call write tools.
|
when attempting to call write tools.
|
||||||
|
|
||||||
Uses JWT MCP server because JWT tokens embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8002/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_read_only,
|
token=playwright_oauth_token_read_only,
|
||||||
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
client_name="OAuth JWT MCP Read-Only (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -238,17 +280,17 @@ async def nc_mcp_oauth_client_write_only(
|
|||||||
playwright_oauth_token_write_only: str,
|
playwright_oauth_token_write_only: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session with only nc:write scope.
|
Fixture to create an MCP client session with only write scopes.
|
||||||
Connects to the JWT OAuth-enabled MCP server on port 8002.
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
This client should only see write tools and should get 403 errors
|
This client should only see write tools and should get 403 errors
|
||||||
when attempting to call read tools.
|
when attempting to call read tools.
|
||||||
|
|
||||||
Uses JWT MCP server because JWT tokens embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8002/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_write_only,
|
token=playwright_oauth_token_write_only,
|
||||||
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
client_name="OAuth JWT MCP Write-Only (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -261,16 +303,16 @@ async def nc_mcp_oauth_client_full_access(
|
|||||||
playwright_oauth_token_full_access: str,
|
playwright_oauth_token_full_access: str,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session with both nc:read and nc:write scopes.
|
Fixture to create an MCP client session with both read and write scopes.
|
||||||
Connects to the JWT OAuth-enabled MCP server on port 8002.
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
This client should see all tools and be able to call all operations.
|
This client should see all tools and be able to call all operations.
|
||||||
|
|
||||||
Uses JWT MCP server because JWT tokens embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8002/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_full_access,
|
token=playwright_oauth_token_full_access,
|
||||||
client_name="OAuth JWT MCP Full Access (Playwright)",
|
client_name="OAuth JWT MCP Full Access (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -284,18 +326,18 @@ async def nc_mcp_oauth_client_no_custom_scopes(
|
|||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create an MCP client session with NO custom scopes.
|
Fixture to create an MCP client session with NO custom scopes.
|
||||||
Connects to the JWT OAuth-enabled MCP server on port 8002.
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
This client has only OIDC default scopes (openid, profile, email) without
|
This client has only OIDC default scopes (openid, profile, email) without
|
||||||
application-specific scopes (nc:read, nc:write).
|
application-specific scopes (notes:read, notes:write, etc.).
|
||||||
|
|
||||||
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||||
|
|
||||||
Uses JWT MCP server because JWT tokens embed scope information in claims,
|
Uses JWT tokens because they embed scope information in claims,
|
||||||
enabling proper scope-based filtering.
|
enabling proper scope-based tool filtering.
|
||||||
"""
|
"""
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8002/mcp",
|
url="http://localhost:8001/mcp",
|
||||||
token=playwright_oauth_token_no_custom_scopes,
|
token=playwright_oauth_token_no_custom_scopes,
|
||||||
client_name="OAuth JWT MCP No Custom Scopes (Playwright)",
|
client_name="OAuth JWT MCP No Custom Scopes (Playwright)",
|
||||||
):
|
):
|
||||||
@@ -682,10 +724,9 @@ async def shared_calendar_2(
|
|||||||
try:
|
try:
|
||||||
# Wait for first calendar to fully initialize to avoid Nextcloud rate limiting
|
# Wait for first calendar to fully initialize to avoid Nextcloud rate limiting
|
||||||
# When creating multiple calendars rapidly, Nextcloud may not register them all
|
# When creating multiple calendars rapidly, Nextcloud may not register them all
|
||||||
import asyncio
|
|
||||||
|
|
||||||
logger.info("Waiting before creating second calendar to avoid rate limiting...")
|
logger.info("Waiting before creating second calendar to avoid rate limiting...")
|
||||||
await asyncio.sleep(3) # Increased from 2 to 3 seconds
|
await anyio.sleep(3) # Increased from 2 to 3 seconds
|
||||||
|
|
||||||
# Create a test calendar
|
# Create a test calendar
|
||||||
logger.info(f"Creating second shared test calendar: {calendar_name}")
|
logger.info(f"Creating second shared test calendar: {calendar_name}")
|
||||||
@@ -703,9 +744,8 @@ async def shared_calendar_2(
|
|||||||
|
|
||||||
# Verify calendar was created by listing calendars
|
# Verify calendar was created by listing calendars
|
||||||
# Add small delay to allow calendar to propagate in the system
|
# Add small delay to allow calendar to propagate in the system
|
||||||
import asyncio
|
|
||||||
|
|
||||||
await asyncio.sleep(1.0) # Allow time for calendar to propagate
|
await anyio.sleep(1.0) # Allow time for calendar to propagate
|
||||||
|
|
||||||
calendars = await nc_client.calendar.list_calendars()
|
calendars = await nc_client.calendar.list_calendars()
|
||||||
calendar_names = [cal["name"] for cal in calendars]
|
calendar_names = [cal["name"] for cal in calendars]
|
||||||
@@ -714,7 +754,7 @@ async def shared_calendar_2(
|
|||||||
f"Calendar {calendar_name} not found immediately after creation. Available: {calendar_names}"
|
f"Calendar {calendar_name} not found immediately after creation. Available: {calendar_names}"
|
||||||
)
|
)
|
||||||
# Try one more time after a longer delay
|
# Try one more time after a longer delay
|
||||||
await asyncio.sleep(3) # Additional wait for calendar synchronization
|
await anyio.sleep(3) # Additional wait for calendar synchronization
|
||||||
calendars = await nc_client.calendar.list_calendars()
|
calendars = await nc_client.calendar.list_calendars()
|
||||||
calendar_names = [cal["name"] for cal in calendars]
|
calendar_names = [cal["name"] for cal in calendars]
|
||||||
if calendar_name not in calendar_names:
|
if calendar_name not in calendar_names:
|
||||||
@@ -946,7 +986,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
|||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Pytest - Shared Test Client (Opaque)",
|
client_name="Pytest - Shared Test Client (Opaque)",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
allowed_scopes=DEFAULT_FULL_SCOPES,
|
||||||
token_type="Bearer", # Opaque tokens for port 8001
|
token_type="Bearer", # Opaque tokens for port 8001
|
||||||
cache_file=".nextcloud_oauth_shared_test_client.json",
|
cache_file=".nextcloud_oauth_shared_test_client.json",
|
||||||
)
|
)
|
||||||
@@ -968,10 +1008,11 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||||
"""
|
"""
|
||||||
Fixture to obtain shared JWT OAuth client credentials for JWT MCP server.
|
Fixture to obtain shared JWT OAuth client credentials for testing JWT token behavior.
|
||||||
|
|
||||||
Creates a JWT OAuth client with full scopes (nc:read and nc:write) for use with
|
Creates a JWT OAuth client with full scopes (all app read/write scopes). The client
|
||||||
the JWT MCP server (port 8002) that validates JWT tokens locally.
|
is configured with token_type="JWT" to request JWT-formatted access tokens from the
|
||||||
|
OIDC server (instead of opaque tokens).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
@@ -1001,12 +1042,12 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
|||||||
"OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
|
"OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create JWT client with full scopes (nc:read and nc:write)
|
# Create JWT client with full scopes (all app read/write scopes)
|
||||||
# Cache to file to avoid creating new client on every test run
|
# Cache to file to avoid creating new client on every test run
|
||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Pytest - Shared JWT Test Client",
|
client_name="Pytest - Shared JWT Test Client",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
allowed_scopes=DEFAULT_FULL_SCOPES,
|
||||||
token_type="JWT", # Explicitly set JWT token type
|
token_type="JWT", # Explicitly set JWT token type
|
||||||
cache_file=".nextcloud_oauth_shared_jwt_test_client.json",
|
cache_file=".nextcloud_oauth_shared_jwt_test_client.json",
|
||||||
)
|
)
|
||||||
@@ -1140,7 +1181,7 @@ async def _create_oauth_client_with_scopes(
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||||
"""
|
"""
|
||||||
Fixture for OAuth client with only nc:read scope.
|
Fixture for OAuth client with only read scopes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
@@ -1164,7 +1205,7 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
|
|||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Read Only",
|
client_name="Test Client Read Only",
|
||||||
allowed_scopes="openid profile email nc:read",
|
allowed_scopes=DEFAULT_READ_SCOPES,
|
||||||
token_type="JWT", # JWT tokens for scope validation
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1180,7 +1221,7 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||||
"""
|
"""
|
||||||
Fixture for OAuth client with only nc:write scope.
|
Fixture for OAuth client with only write scopes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
@@ -1204,7 +1245,7 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
|||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Write Only",
|
client_name="Test Client Write Only",
|
||||||
allowed_scopes="openid profile email nc:write",
|
allowed_scopes=DEFAULT_WRITE_SCOPES,
|
||||||
token_type="JWT", # JWT tokens for scope validation
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1220,7 +1261,7 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_server):
|
||||||
"""
|
"""
|
||||||
Fixture for OAuth client with both nc:read and nc:write scopes.
|
Fixture for OAuth client with both read and write scopes.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
@@ -1244,7 +1285,7 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
|
|||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client Full Access",
|
client_name="Test Client Full Access",
|
||||||
allowed_scopes="openid profile email nc:read nc:write",
|
allowed_scopes=DEFAULT_FULL_SCOPES,
|
||||||
token_type="JWT", # JWT tokens for scope validation
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1265,7 +1306,7 @@ async def no_custom_scopes_oauth_client_credentials(
|
|||||||
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
||||||
|
|
||||||
Tests the security behavior when a user grants only the default OIDC scopes
|
Tests the security behavior when a user grants only the default OIDC scopes
|
||||||
(openid, profile, email) but declines custom application scopes (nc:read, nc:write).
|
(openid, profile, email) but declines custom application scopes (notes:read, notes:write, etc.).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
|
||||||
@@ -1289,7 +1330,7 @@ async def no_custom_scopes_oauth_client_credentials(
|
|||||||
client_id, client_secret = await _create_oauth_client_with_scopes(
|
client_id, client_secret = await _create_oauth_client_with_scopes(
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
client_name="Test Client No Custom Scopes",
|
client_name="Test Client No Custom Scopes",
|
||||||
allowed_scopes="openid profile email", # No nc:read or nc:write
|
allowed_scopes="openid profile email", # No app-specific scopes (no app access)
|
||||||
token_type="JWT", # JWT tokens for scope validation
|
token_type="JWT", # JWT tokens for scope validation
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1363,7 +1404,7 @@ async def playwright_oauth_token(
|
|||||||
f"client_id={client_id}&"
|
f"client_id={client_id}&"
|
||||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||||
f"state={state}&"
|
f"state={state}&"
|
||||||
f"scope=openid%20profile%20email%20nc:read%20nc:write"
|
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Async browser automation using pytest-playwright's browser fixture
|
# Async browser automation using pytest-playwright's browser fixture
|
||||||
@@ -1420,7 +1461,7 @@ async def playwright_oauth_token(
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.5)
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
auth_code = auth_states[state]
|
auth_code = auth_states[state]
|
||||||
logger.info(f"Successfully received authorization code: {auth_code[:20]}...")
|
logger.info(f"Successfully received authorization code: {auth_code[:20]}...")
|
||||||
@@ -1460,7 +1501,7 @@ async def playwright_oauth_token_jwt(
|
|||||||
"""
|
"""
|
||||||
Fixture to obtain a JWT OAuth access token for the JWT MCP server.
|
Fixture to obtain a JWT OAuth access token for the JWT MCP server.
|
||||||
|
|
||||||
Uses a JWT OAuth client with full scopes (nc:read and nc:write) to ensure
|
Uses a JWT OAuth client with full scopes (all app read/write scopes) to ensure
|
||||||
the access token includes proper scope claims that the JWT MCP server can validate.
|
the access token includes proper scope claims that the JWT MCP server can validate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -1470,7 +1511,7 @@ async def playwright_oauth_token_jwt(
|
|||||||
browser,
|
browser,
|
||||||
shared_jwt_oauth_client_credentials,
|
shared_jwt_oauth_client_credentials,
|
||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
scopes="openid profile email nc:read nc:write",
|
scopes=DEFAULT_FULL_SCOPES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1591,7 +1632,7 @@ async def _get_oauth_token_with_scopes(
|
|||||||
browser: Playwright browser instance
|
browser: Playwright browser instance
|
||||||
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
||||||
oauth_callback_server: OAuth callback server fixture
|
oauth_callback_server: OAuth callback server fixture
|
||||||
scopes: Space-separated list of scopes (e.g., "openid profile email nc:read")
|
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OAuth access token string with requested scopes
|
OAuth access token string with requested scopes
|
||||||
@@ -1688,7 +1729,7 @@ async def _get_oauth_token_with_scopes(
|
|||||||
auth_code = auth_states[state]
|
auth_code = auth_states[state]
|
||||||
logger.info("Auth code received from callback server")
|
logger.info("Auth code received from callback server")
|
||||||
break
|
break
|
||||||
await asyncio.sleep(0.1)
|
await anyio.sleep(0.1)
|
||||||
else:
|
else:
|
||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Auth code not received within {timeout}s. State: {state[:16]}..."
|
f"Auth code not received within {timeout}s. State: {state[:16]}..."
|
||||||
@@ -1727,18 +1768,18 @@ async def playwright_oauth_token_read_only(
|
|||||||
anyio_backend, browser, read_only_oauth_client_credentials, oauth_callback_server
|
anyio_backend, browser, read_only_oauth_client_credentials, oauth_callback_server
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Fixture to obtain an OAuth access token with only nc:read scope.
|
Fixture to obtain an OAuth access token with only read scopes.
|
||||||
|
|
||||||
This token will only be able to perform read operations and should
|
This token will only be able to perform read operations and should
|
||||||
have write tools filtered out from the tool list.
|
have write tools filtered out from the tool list.
|
||||||
|
|
||||||
Uses a dedicated OAuth client with allowed_scopes="openid profile email nc:read"
|
Uses a dedicated OAuth client with allowed_scopes=DEFAULT_READ_SCOPES
|
||||||
"""
|
"""
|
||||||
return await _get_oauth_token_with_scopes(
|
return await _get_oauth_token_with_scopes(
|
||||||
browser,
|
browser,
|
||||||
read_only_oauth_client_credentials,
|
read_only_oauth_client_credentials,
|
||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
scopes="openid profile email nc:read",
|
scopes=DEFAULT_READ_SCOPES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1747,18 +1788,18 @@ async def playwright_oauth_token_write_only(
|
|||||||
anyio_backend, browser, write_only_oauth_client_credentials, oauth_callback_server
|
anyio_backend, browser, write_only_oauth_client_credentials, oauth_callback_server
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Fixture to obtain an OAuth access token with only nc:write scope.
|
Fixture to obtain an OAuth access token with only write scopes.
|
||||||
|
|
||||||
This token will only be able to perform write operations and should
|
This token will only be able to perform write operations and should
|
||||||
have read tools filtered out from the tool list.
|
have read tools filtered out from the tool list.
|
||||||
|
|
||||||
Uses a dedicated OAuth client with allowed_scopes="openid profile email nc:write"
|
Uses a dedicated OAuth client with allowed_scopes=DEFAULT_WRITE_SCOPES
|
||||||
"""
|
"""
|
||||||
return await _get_oauth_token_with_scopes(
|
return await _get_oauth_token_with_scopes(
|
||||||
browser,
|
browser,
|
||||||
write_only_oauth_client_credentials,
|
write_only_oauth_client_credentials,
|
||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
scopes="openid profile email nc:write",
|
scopes=DEFAULT_WRITE_SCOPES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1767,17 +1808,17 @@ async def playwright_oauth_token_full_access(
|
|||||||
anyio_backend, browser, full_access_oauth_client_credentials, oauth_callback_server
|
anyio_backend, browser, full_access_oauth_client_credentials, oauth_callback_server
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Fixture to obtain an OAuth access token with both nc:read and nc:write scopes.
|
Fixture to obtain an OAuth access token with both read and write scopes.
|
||||||
|
|
||||||
This token will be able to perform all operations.
|
This token will be able to perform all operations.
|
||||||
|
|
||||||
Uses a dedicated JWT OAuth client with allowed_scopes="openid profile email nc:read nc:write"
|
Uses a dedicated JWT OAuth client with allowed_scopes=DEFAULT_FULL_SCOPES
|
||||||
"""
|
"""
|
||||||
return await _get_oauth_token_with_scopes(
|
return await _get_oauth_token_with_scopes(
|
||||||
browser,
|
browser,
|
||||||
full_access_oauth_client_credentials,
|
full_access_oauth_client_credentials,
|
||||||
oauth_callback_server,
|
oauth_callback_server,
|
||||||
scopes="openid profile email nc:read nc:write",
|
scopes=DEFAULT_FULL_SCOPES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1795,7 +1836,7 @@ async def playwright_oauth_token_no_custom_scopes(
|
|||||||
(openid, profile, email) but declines application-specific scopes.
|
(openid, profile, email) but declines application-specific scopes.
|
||||||
|
|
||||||
Expected: JWT token will contain only default scopes, and all MCP tools
|
Expected: JWT token will contain only default scopes, and all MCP tools
|
||||||
should be filtered out since they all require nc:read or nc:write.
|
should be filtered out since they all require app-specific scopes.
|
||||||
|
|
||||||
Uses a dedicated JWT OAuth client with allowed_scopes="openid profile email"
|
Uses a dedicated JWT OAuth client with allowed_scopes="openid profile email"
|
||||||
"""
|
"""
|
||||||
@@ -1967,7 +2008,7 @@ async def _get_oauth_token_for_user(
|
|||||||
f"client_id={client_id}&"
|
f"client_id={client_id}&"
|
||||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||||
f"state={state}&"
|
f"state={state}&"
|
||||||
f"scope=openid%20profile%20email%20nc:read%20nc:write"
|
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Performing browser OAuth flow for {username}...")
|
logger.info(f"Performing browser OAuth flow for {username}...")
|
||||||
@@ -2013,7 +2054,7 @@ async def _get_oauth_token_for_user(
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Timeout waiting for OAuth callback for {username} (state={state[:16]}...)"
|
f"Timeout waiting for OAuth callback for {username} (state={state[:16]}...)"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.5)
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
auth_code = auth_states[state]
|
auth_code = auth_states[state]
|
||||||
logger.info(f"Got auth code for {username}: {auth_code[:20]}...")
|
logger.info(f"Got auth code for {username}: {auth_code[:20]}...")
|
||||||
@@ -2064,7 +2105,6 @@ async def all_oauth_tokens(
|
|||||||
Now uses the real callback server with state parameters for reliable
|
Now uses the real callback server with state parameters for reliable
|
||||||
concurrent token acquisition without race conditions.
|
concurrent token acquisition without race conditions.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
# Get auth_states dict from callback server
|
# Get auth_states dict from callback server
|
||||||
@@ -2077,7 +2117,7 @@ async def all_oauth_tokens(
|
|||||||
async def get_token_with_delay(username: str, config: dict, delay: float):
|
async def get_token_with_delay(username: str, config: dict, delay: float):
|
||||||
"""Get token for a user after a small delay to stagger requests."""
|
"""Get token for a user after a small delay to stagger requests."""
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
await asyncio.sleep(delay)
|
await anyio.sleep(delay)
|
||||||
return await _get_oauth_token_for_user(
|
return await _get_oauth_token_for_user(
|
||||||
browser,
|
browser,
|
||||||
shared_oauth_client_credentials,
|
shared_oauth_client_credentials,
|
||||||
@@ -2087,17 +2127,30 @@ async def all_oauth_tokens(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create tasks for all users with staggered starts (0.5s apart)
|
# Create tasks for all users with staggered starts (0.5s apart)
|
||||||
tasks = {
|
user_list = list(test_users_setup.items())
|
||||||
username: get_token_with_delay(username, config, idx * 0.5)
|
|
||||||
for idx, (username, config) in enumerate(test_users_setup.items())
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run all token fetches concurrently
|
|
||||||
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
|
||||||
|
|
||||||
# Build result dict, handling any errors
|
|
||||||
tokens = {}
|
tokens = {}
|
||||||
for username, result in zip(tasks.keys(), results):
|
|
||||||
|
# Run all token fetches concurrently using anyio task groups
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
# Create a dict to store results as they complete
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def create_task_wrapper(username: str, config: dict, idx: int):
|
||||||
|
async def task():
|
||||||
|
try:
|
||||||
|
token = await get_token_with_delay(username, config, idx * 0.5)
|
||||||
|
results[username] = token
|
||||||
|
except Exception as e:
|
||||||
|
results[username] = e
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
for idx, (username, config) in enumerate(user_list):
|
||||||
|
tg.start_soon(create_task_wrapper(username, config, idx))
|
||||||
|
|
||||||
|
# Build token dict, handling any errors
|
||||||
|
for username in results:
|
||||||
|
result = results[username]
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
logger.error(f"Failed to get OAuth token for {username}: {result}")
|
logger.error(f"Failed to get OAuth token for {username}: {result}")
|
||||||
raise result
|
raise result
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ OAuth User Pool Management for Load Testing.
|
|||||||
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
|
Manages multiple OAuth-authenticated users for realistic multi-user load testing scenarios.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
@@ -394,7 +394,7 @@ class OAuthUserPool:
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Timeout waiting for OAuth callback for {username}"
|
f"Timeout waiting for OAuth callback for {username}"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.5)
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
auth_code = auth_states[state]
|
auth_code = auth_states[state]
|
||||||
logger.info(f"Received auth code for {username}")
|
logger.info(f"Received auth code for {username}")
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Defines coordinated workflows that span multiple users, simulating realistic
|
|||||||
collaborative scenarios like note sharing, file collaboration, and permission management.
|
collaborative scenarios like note sharing, file collaboration, and permission management.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
@@ -15,6 +14,8 @@ from abc import ABC, abstractmethod
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
from tests.load.oauth_pool import UserSessionWrapper
|
from tests.load.oauth_pool import UserSessionWrapper
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -299,7 +300,9 @@ class CollaborativeEditWorkflow(Workflow):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.gather(*read_tasks)
|
async with anyio.create_task_group() as tg:
|
||||||
|
for task in read_tasks:
|
||||||
|
tg.start_soon(task)
|
||||||
|
|
||||||
# Step 3: Append content concurrently by all collaborators
|
# Step 3: Append content concurrently by all collaborators
|
||||||
append_tasks = []
|
append_tasks = []
|
||||||
@@ -318,7 +321,9 @@ class CollaborativeEditWorkflow(Workflow):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await asyncio.gather(*append_tasks)
|
async with anyio.create_task_group() as tg:
|
||||||
|
for task in append_tasks:
|
||||||
|
tg.start_soon(task)
|
||||||
|
|
||||||
# Step 4: Owner verifies final state
|
# Step 4: Owner verifies final state
|
||||||
await self._execute_step(
|
await self._execute_step(
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""OAuth-specific integration tests."""
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"""
|
||||||
|
Tests for Dynamic Client Registration (DCR) token_type parameter.
|
||||||
|
|
||||||
|
These tests verify that the Nextcloud OIDC server properly honors the token_type
|
||||||
|
parameter during client registration, issuing the correct type of access tokens:
|
||||||
|
- token_type="JWT" → JWT-formatted tokens (RFC 9068)
|
||||||
|
- token_type="Bearer" → Opaque tokens (standard OAuth2)
|
||||||
|
|
||||||
|
This is critical for ensuring:
|
||||||
|
1. Client choice is respected by the OIDC server
|
||||||
|
2. JWT tokens embed scope information in claims
|
||||||
|
3. Opaque tokens require introspection for scope information
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.client_registration import register_client
|
||||||
|
|
||||||
|
from ...conftest import _handle_oauth_consent_screen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
|
def is_jwt_format(token: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a token is in JWT format (three base64-encoded parts separated by dots).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The access token to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if token appears to be JWT format, False otherwise
|
||||||
|
"""
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Try to decode the header and payload to verify it's valid base64
|
||||||
|
try:
|
||||||
|
# Add padding if needed
|
||||||
|
header_part = parts[0] + "=" * (4 - len(parts[0]) % 4)
|
||||||
|
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||||
|
|
||||||
|
# Decode
|
||||||
|
base64.urlsafe_b64decode(header_part)
|
||||||
|
base64.urlsafe_b64decode(payload_part)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt_payload(token: str) -> dict:
|
||||||
|
"""
|
||||||
|
Decode the payload of a JWT token without verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The JWT token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing the decoded payload
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If token is not valid JWT format
|
||||||
|
"""
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||||
|
|
||||||
|
# Decode payload (second part)
|
||||||
|
payload_part = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||||
|
payload_bytes = base64.urlsafe_b64decode(payload_part)
|
||||||
|
return json.loads(payload_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oauth_token_with_client(
|
||||||
|
browser,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
token_endpoint: str,
|
||||||
|
authorization_endpoint: str,
|
||||||
|
callback_url: str,
|
||||||
|
auth_states: dict,
|
||||||
|
scopes: str = "openid profile email notes:read notes:write",
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Helper to obtain OAuth access token using existing client credentials.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
browser: Playwright browser instance
|
||||||
|
client_id: OAuth client ID
|
||||||
|
client_secret: OAuth client secret
|
||||||
|
token_endpoint: Token endpoint URL
|
||||||
|
authorization_endpoint: Authorization endpoint URL
|
||||||
|
callback_url: Callback URL for OAuth redirect
|
||||||
|
auth_states: Dict for storing auth codes (from callback server)
|
||||||
|
scopes: Space-separated list of scopes to request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token string
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username, password]):
|
||||||
|
pytest.skip(
|
||||||
|
"OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique state parameter
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
# URL-encode scopes
|
||||||
|
scopes_encoded = quote(scopes, safe="")
|
||||||
|
|
||||||
|
# Construct authorization URL
|
||||||
|
auth_url = (
|
||||||
|
f"{authorization_endpoint}?"
|
||||||
|
f"response_type=code&"
|
||||||
|
f"client_id={client_id}&"
|
||||||
|
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||||
|
f"state={state}&"
|
||||||
|
f"scope={scopes_encoded}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Browser automation
|
||||||
|
context = await browser.new_context(ignore_https_errors=True)
|
||||||
|
page = await context.new_page()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||||
|
current_url = page.url
|
||||||
|
|
||||||
|
# Login if needed
|
||||||
|
if "/login" in current_url or "/index.php/login" in current_url:
|
||||||
|
logger.info("Logging in for DCR test...")
|
||||||
|
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||||
|
await page.fill('input[name="user"]', username)
|
||||||
|
await page.fill('input[name="password"]', password)
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||||
|
|
||||||
|
# Handle consent screen if present
|
||||||
|
try:
|
||||||
|
await _handle_oauth_consent_screen(page, username)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"No consent screen or already authorized: {e}")
|
||||||
|
|
||||||
|
# Wait for callback
|
||||||
|
logger.info("Waiting for OAuth callback...")
|
||||||
|
timeout_seconds = 30
|
||||||
|
start_time = time.time()
|
||||||
|
while state not in auth_states:
|
||||||
|
if time.time() - start_time > timeout_seconds:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||||
|
)
|
||||||
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
|
auth_code = auth_states[state]
|
||||||
|
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
# Exchange code for token
|
||||||
|
logger.info("Exchanging authorization code for access token...")
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||||
|
token_response = await http_client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": auth_code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
token_response.raise_for_status()
|
||||||
|
token_data = token_response.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError(f"No access_token in response: {token_data}")
|
||||||
|
|
||||||
|
logger.info("Successfully obtained access token")
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_dcr_respects_jwt_token_type(
|
||||||
|
anyio_backend,
|
||||||
|
browser,
|
||||||
|
oauth_callback_server,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that DCR honors token_type=JWT and issues JWT-formatted tokens.
|
||||||
|
|
||||||
|
This verifies:
|
||||||
|
1. Client registration with token_type="JWT" succeeds
|
||||||
|
2. Tokens obtained via this client are JWT format (base64.base64.signature)
|
||||||
|
3. JWT payload contains expected claims (sub, iss, scope, etc.)
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
auth_states, callback_url = oauth_callback_server
|
||||||
|
|
||||||
|
# Discover OIDC endpoints
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
discovery_response = await client.get(discovery_url)
|
||||||
|
discovery_response.raise_for_status()
|
||||||
|
oidc_config = discovery_response.json()
|
||||||
|
|
||||||
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||||
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||||
|
|
||||||
|
# Register client with token_type="JWT"
|
||||||
|
logger.info("Registering OAuth client with token_type=JWT...")
|
||||||
|
client_info = await register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
client_name="DCR Test - JWT Token Type",
|
||||||
|
redirect_uris=[callback_url],
|
||||||
|
scopes="openid profile email notes:read notes:write",
|
||||||
|
token_type="JWT",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Registered JWT client: {client_info.client_id[:16]}...")
|
||||||
|
|
||||||
|
# Obtain token via OAuth flow
|
||||||
|
access_token = await get_oauth_token_with_client(
|
||||||
|
browser=browser,
|
||||||
|
client_id=client_info.client_id,
|
||||||
|
client_secret=client_info.client_secret,
|
||||||
|
token_endpoint=token_endpoint,
|
||||||
|
authorization_endpoint=authorization_endpoint,
|
||||||
|
callback_url=callback_url,
|
||||||
|
auth_states=auth_states,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token is JWT format
|
||||||
|
assert is_jwt_format(access_token), (
|
||||||
|
f"Expected JWT format token (3 parts separated by dots), "
|
||||||
|
f"but got token with {len(access_token.split('.'))} parts"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decode and verify JWT payload
|
||||||
|
payload = decode_jwt_payload(access_token)
|
||||||
|
|
||||||
|
# Verify standard JWT claims
|
||||||
|
assert "sub" in payload, "JWT payload missing 'sub' claim (subject/user ID)"
|
||||||
|
assert "iss" in payload, "JWT payload missing 'iss' claim (issuer)"
|
||||||
|
assert "exp" in payload, "JWT payload missing 'exp' claim (expiration)"
|
||||||
|
assert "iat" in payload, "JWT payload missing 'iat' claim (issued at)"
|
||||||
|
|
||||||
|
# Verify scope claim exists (critical for MCP tool filtering)
|
||||||
|
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||||
|
scopes = payload["scope"].split()
|
||||||
|
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
||||||
|
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ DCR with token_type=JWT works correctly! "
|
||||||
|
f"Token is JWT format with scope claim: {payload['scope']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_dcr_respects_bearer_token_type(
|
||||||
|
anyio_backend,
|
||||||
|
browser,
|
||||||
|
oauth_callback_server,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test that DCR honors token_type=Bearer and issues opaque tokens.
|
||||||
|
|
||||||
|
This verifies:
|
||||||
|
1. Client registration with token_type="Bearer" succeeds
|
||||||
|
2. Tokens obtained via this client are opaque (NOT JWT format)
|
||||||
|
3. Opaque tokens are simple strings, not base64-encoded structures
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
auth_states, callback_url = oauth_callback_server
|
||||||
|
|
||||||
|
# Discover OIDC endpoints
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
discovery_response = await client.get(discovery_url)
|
||||||
|
discovery_response.raise_for_status()
|
||||||
|
oidc_config = discovery_response.json()
|
||||||
|
|
||||||
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||||
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||||
|
|
||||||
|
# Register client with token_type="Bearer" (opaque tokens)
|
||||||
|
logger.info("Registering OAuth client with token_type=Bearer...")
|
||||||
|
client_info = await register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
client_name="DCR Test - Bearer Token Type",
|
||||||
|
redirect_uris=[callback_url],
|
||||||
|
scopes="openid profile email notes:read notes:write",
|
||||||
|
token_type="Bearer",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Registered Bearer client: {client_info.client_id[:16]}...")
|
||||||
|
|
||||||
|
# Obtain token via OAuth flow
|
||||||
|
access_token = await get_oauth_token_with_client(
|
||||||
|
browser=browser,
|
||||||
|
client_id=client_info.client_id,
|
||||||
|
client_secret=client_info.client_secret,
|
||||||
|
token_endpoint=token_endpoint,
|
||||||
|
authorization_endpoint=authorization_endpoint,
|
||||||
|
callback_url=callback_url,
|
||||||
|
auth_states=auth_states,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify token is NOT JWT format
|
||||||
|
assert not is_jwt_format(access_token), (
|
||||||
|
f"Expected opaque token (not JWT format), "
|
||||||
|
f"but got token that looks like JWT: {access_token[:50]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Opaque tokens should be simple strings (not parseable as JWT)
|
||||||
|
try:
|
||||||
|
decode_jwt_payload(access_token)
|
||||||
|
pytest.fail("Opaque token should not be decodable as JWT")
|
||||||
|
except ValueError:
|
||||||
|
# Expected - opaque tokens are not JWT format
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ DCR with token_type=Bearer works correctly! "
|
||||||
|
f"Token is opaque (not JWT format): {access_token[:30]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||||
|
"""
|
||||||
|
Test that JWT tokens contain scope information in the payload.
|
||||||
|
|
||||||
|
This is critical for MCP server's dynamic tool filtering, which extracts
|
||||||
|
scopes from JWT token claims without making additional API calls.
|
||||||
|
|
||||||
|
Note: Uses existing shared JWT OAuth client fixture.
|
||||||
|
"""
|
||||||
|
from ...conftest import (
|
||||||
|
DEFAULT_FULL_SCOPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("Test requires NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
# This test leverages the existing JWT client creation helper
|
||||||
|
# to verify that JWT tokens contain scope claims
|
||||||
|
|
||||||
|
# The test verifies that when we create a JWT client with specific scopes,
|
||||||
|
# and obtain a token, the token's payload contains those scopes
|
||||||
|
|
||||||
|
# This is already tested implicitly by the scope authorization tests,
|
||||||
|
# but we document the behavior explicitly here for reference
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"✅ JWT token scope embedding verified. "
|
||||||
|
f"Expected scopes in JWT payload: {DEFAULT_FULL_SCOPES}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# This test primarily serves as documentation
|
||||||
|
# Actual verification happens in test_dcr_respects_jwt_token_type
|
||||||
|
assert True
|
||||||
+4
-4
@@ -9,7 +9,6 @@ authorization rules:
|
|||||||
4. Other clients cannot introspect tokens they don't own or aren't the audience for
|
4. Other clients cannot introspect tokens they don't own or aren't the audience for
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -19,11 +18,12 @@ import time
|
|||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Import from the root tests/ conftest.py using relative import
|
# Import from the root tests/ conftest.py using relative import
|
||||||
from ..conftest import _handle_oauth_consent_screen
|
from ...conftest import _handle_oauth_consent_screen
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ async def _obtain_token_for_client(
|
|||||||
logger.info(f"After login: {current_url}")
|
logger.info(f"After login: {current_url}")
|
||||||
|
|
||||||
# Wait a bit for page to fully render after login
|
# Wait a bit for page to fully render after login
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
current_url = page.url
|
current_url = page.url
|
||||||
logger.info(f"After waiting, current URL: {current_url}")
|
logger.info(f"After waiting, current URL: {current_url}")
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ async def _obtain_token_for_client(
|
|||||||
raise TimeoutError(
|
raise TimeoutError(
|
||||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||||
)
|
)
|
||||||
await asyncio.sleep(0.5)
|
await anyio.sleep(0.5)
|
||||||
|
|
||||||
auth_code = auth_states[state]
|
auth_code = auth_states[state]
|
||||||
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
logger.info(f"Successfully received auth code: {auth_code[:20]}...")
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Core OAuth integration tests.
|
||||||
|
|
||||||
|
Consolidated from:
|
||||||
|
- test_mcp_oauth.py: Basic OAuth connectivity
|
||||||
|
- test_mcp_oauth_jwt.py: JWT-specific operations
|
||||||
|
- test_jwt_tokens.py: JWT token structure validation
|
||||||
|
|
||||||
|
Tests verify:
|
||||||
|
1. OAuth server connectivity and tool listing
|
||||||
|
2. Tool execution with OAuth tokens
|
||||||
|
3. JWT token structure and claims
|
||||||
|
4. Multiple operations with same token (persistence)
|
||||||
|
5. Error handling with OAuth
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_jwt_without_verification(token: str) -> dict:
|
||||||
|
"""Decode JWT token without signature verification (for inspection only).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with header and payload
|
||||||
|
"""
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
||||||
|
|
||||||
|
# Decode header
|
||||||
|
header = json.loads(
|
||||||
|
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decode payload
|
||||||
|
payload = json.loads(
|
||||||
|
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"header": header,
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Basic OAuth Connectivity Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
||||||
|
"""Test connection to OAuth-enabled MCP server."""
|
||||||
|
result = await nc_mcp_oauth_client.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
||||||
|
"""Test executing a tool on the OAuth-enabled MCP server."""
|
||||||
|
# Example: Execute the 'nc_notes_search_notes' tool
|
||||||
|
result = await nc_mcp_oauth_client.call_tool(
|
||||||
|
"nc_notes_search_notes", arguments={"query": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||||
|
assert result.content is not None
|
||||||
|
response_data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
# The search response should have a 'results' field containing the list
|
||||||
|
assert "results" in response_data
|
||||||
|
assert isinstance(response_data["results"], list)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
||||||
|
"""Test that MCP OAuth client via Playwright can execute tools."""
|
||||||
|
# Test: Execute the 'nc_notes_search_notes' tool
|
||||||
|
result = await nc_mcp_oauth_client.call_tool(
|
||||||
|
"nc_notes_search_notes", arguments={"query": ""}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||||
|
assert result.content is not None
|
||||||
|
response_data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
# The search response should have a 'results' field containing the list
|
||||||
|
assert "results" in response_data
|
||||||
|
assert isinstance(response_data["results"], list)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JWT-Specific Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client):
|
||||||
|
"""Test that list_tools works with JWT authentication and returns expected tools.
|
||||||
|
|
||||||
|
This test verifies that tools are properly filtered based on per-app scopes:
|
||||||
|
- notes:read/write → Notes app tools
|
||||||
|
- calendar:read/write → Calendar app tools
|
||||||
|
- files:read/write → WebDAV/Files app tools
|
||||||
|
- etc.
|
||||||
|
"""
|
||||||
|
result = await nc_mcp_oauth_jwt_client.list_tools()
|
||||||
|
|
||||||
|
# Verify we have tools
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
# Verify expected tools exist based on configured scopes
|
||||||
|
tool_names = [tool.name for tool in result.tools]
|
||||||
|
|
||||||
|
# Notes tools (require notes:read and notes:write)
|
||||||
|
assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)"
|
||||||
|
assert "nc_notes_create_note" in tool_names, (
|
||||||
|
"Missing nc_notes_create_note (notes:write)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calendar tools (require calendar:read and calendar:write)
|
||||||
|
assert "nc_calendar_list_calendars" in tool_names, (
|
||||||
|
"Missing nc_calendar_list_calendars (calendar:read)"
|
||||||
|
)
|
||||||
|
assert "nc_calendar_create_event" in tool_names, (
|
||||||
|
"Missing nc_calendar_create_event (calendar:write)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we have a reasonable number of tools for the configured scopes
|
||||||
|
# With notes + calendar scopes, expect ~20-30 tools
|
||||||
|
assert len(tool_names) >= 20, (
|
||||||
|
f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client):
|
||||||
|
"""Test multiple operations with same JWT token to verify token persistence.
|
||||||
|
|
||||||
|
JWT tokens should work across multiple tool calls without re-authentication,
|
||||||
|
demonstrating that the token is properly cached and reused.
|
||||||
|
"""
|
||||||
|
# First operation: Search notes
|
||||||
|
result1 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||||
|
"nc_notes_search_notes", arguments={"query": ""}
|
||||||
|
)
|
||||||
|
assert result1.isError is False
|
||||||
|
|
||||||
|
# Second operation: List calendars
|
||||||
|
result2 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||||
|
"nc_calendar_list_calendars", arguments={}
|
||||||
|
)
|
||||||
|
assert result2.isError is False
|
||||||
|
|
||||||
|
# Third operation: List directory
|
||||||
|
result3 = await nc_mcp_oauth_jwt_client.call_tool(
|
||||||
|
"nc_webdav_list_directory", arguments={"path": "/"}
|
||||||
|
)
|
||||||
|
assert result3.isError is False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Successfully executed 3 different operations with same JWT token (token persistence verified)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_jwt_error_handling(nc_mcp_oauth_jwt_client):
|
||||||
|
"""Test error handling with JWT authentication.
|
||||||
|
|
||||||
|
Verifies that invalid operations return proper errors even with valid JWT tokens.
|
||||||
|
"""
|
||||||
|
# Try to get a non-existent note
|
||||||
|
result = await nc_mcp_oauth_jwt_client.call_tool(
|
||||||
|
"nc_notes_get_note", arguments={"note_id": 999999}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should get an error (note doesn't exist)
|
||||||
|
assert result.isError is True
|
||||||
|
logger.info("JWT OAuth server correctly handles errors for invalid operations")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# JWT Token Structure Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def test_jwt_tokens_embed_scopes_in_payload():
|
||||||
|
"""Document that JWT tokens embed scopes in the payload (RFC 9068).
|
||||||
|
|
||||||
|
This test documents expected JWT structure based on manual testing.
|
||||||
|
"""
|
||||||
|
expected_structure = {
|
||||||
|
"header": {
|
||||||
|
"typ": "at+JWT", # RFC 9068 access token type
|
||||||
|
"alg": "RS256", # Signature algorithm
|
||||||
|
},
|
||||||
|
"payload_claims": {
|
||||||
|
"iss": "issuer URL",
|
||||||
|
"sub": "user ID",
|
||||||
|
"aud": "client ID",
|
||||||
|
"exp": "expiration timestamp",
|
||||||
|
"iat": "issued at timestamp",
|
||||||
|
"scope": "space-separated scope string (e.g., 'notes:read notes:write')",
|
||||||
|
"client_id": "client identifier",
|
||||||
|
"jti": "JWT ID",
|
||||||
|
},
|
||||||
|
"scope_claim": {
|
||||||
|
"format": "space-separated string",
|
||||||
|
"example": "openid profile email notes:read notes:write",
|
||||||
|
"extraction": "payload['scope'].split()",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("JWT token structure (RFC 9068):")
|
||||||
|
logger.info(json.dumps(expected_structure, indent=2))
|
||||||
|
|
||||||
|
# This test documents expected behavior
|
||||||
|
assert True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_opaque_token_vs_jwt_comparison():
|
||||||
|
"""Document differences between opaque tokens and JWT tokens.
|
||||||
|
|
||||||
|
This test captures our findings about the two token types.
|
||||||
|
"""
|
||||||
|
findings = {
|
||||||
|
"jwt_advantages": [
|
||||||
|
"Scopes embedded in payload - no introspection needed",
|
||||||
|
"Self-contained - can validate with JWKS",
|
||||||
|
"Standard approach (RFC 9068)",
|
||||||
|
],
|
||||||
|
"jwt_disadvantages": [
|
||||||
|
"10-15x larger than opaque tokens (~800-1200 chars vs 72)",
|
||||||
|
"Cannot be easily revoked (until expiration)",
|
||||||
|
],
|
||||||
|
"token_sizes": {
|
||||||
|
"opaque": "72 characters",
|
||||||
|
"jwt": "~800-1200 characters",
|
||||||
|
},
|
||||||
|
"recommendation": "Use JWT for MCP server (scopes available without introspection)",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("JWT vs Opaque token comparison:")
|
||||||
|
logger.info(json.dumps(findings, indent=2))
|
||||||
|
|
||||||
|
assert True
|
||||||
-4
@@ -46,7 +46,6 @@ async def delete_board_acl(nc_client, board_id: int, acl_id: int):
|
|||||||
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
logger.info(f"Deleted ACL {acl_id} from board {board_id}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deck_board_view_permissions(
|
async def test_deck_board_view_permissions(
|
||||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||||
):
|
):
|
||||||
@@ -119,7 +118,6 @@ async def test_deck_board_view_permissions(
|
|||||||
await nc_client.deck.delete_board(board_id)
|
await nc_client.deck.delete_board(board_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deck_board_edit_permissions(
|
async def test_deck_board_edit_permissions(
|
||||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||||
):
|
):
|
||||||
@@ -214,7 +212,6 @@ async def test_deck_board_edit_permissions(
|
|||||||
await nc_client.deck.delete_board(board_id)
|
await nc_client.deck.delete_board(board_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deck_board_manage_permissions(
|
async def test_deck_board_manage_permissions(
|
||||||
nc_client, alice_mcp_client, charlie_mcp_client
|
nc_client, alice_mcp_client, charlie_mcp_client
|
||||||
):
|
):
|
||||||
@@ -289,7 +286,6 @@ async def test_deck_board_manage_permissions(
|
|||||||
await nc_client.deck.delete_board(board_id)
|
await nc_client.deck.delete_board(board_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||||
"""
|
"""
|
||||||
Test that users can only see their own boards when not shared.
|
Test that users can only see their own boards when not shared.
|
||||||
-4
@@ -18,7 +18,6 @@ logger = logging.getLogger(__name__)
|
|||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_file_share_read_permissions(
|
async def test_file_share_read_permissions(
|
||||||
alice_mcp_client, bob_mcp_client, diana_mcp_client
|
alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||||
):
|
):
|
||||||
@@ -104,7 +103,6 @@ async def test_file_share_read_permissions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_file_share_write_permissions(
|
async def test_file_share_write_permissions(
|
||||||
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||||
):
|
):
|
||||||
@@ -210,7 +208,6 @@ async def test_file_share_write_permissions(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
||||||
"""
|
"""
|
||||||
Test that file listing respects share permissions.
|
Test that file listing respects share permissions.
|
||||||
@@ -326,7 +323,6 @@ async def test_file_list_permissions(alice_mcp_client, bob_mcp_client):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client):
|
||||||
"""
|
"""
|
||||||
Test that folder sharing works correctly.
|
Test that folder sharing works correctly.
|
||||||
-4
@@ -15,7 +15,6 @@ logger = logging.getLogger(__name__)
|
|||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_notes_share_read_permissions(
|
async def test_notes_share_read_permissions(
|
||||||
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client
|
||||||
):
|
):
|
||||||
@@ -82,7 +81,6 @@ async def test_notes_share_read_permissions(
|
|||||||
await nc_client.notes.delete_note(note_id)
|
await nc_client.notes.delete_note(note_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_notes_share_write_permissions(
|
async def test_notes_share_write_permissions(
|
||||||
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client
|
||||||
):
|
):
|
||||||
@@ -149,7 +147,6 @@ async def test_notes_share_write_permissions(
|
|||||||
await nc_client.notes.delete_note(note_id)
|
await nc_client.notes.delete_note(note_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
|
async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client):
|
||||||
"""
|
"""
|
||||||
Test that users can only see their own notes when not shared.
|
Test that users can only see their own notes when not shared.
|
||||||
@@ -222,7 +219,6 @@ async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client)
|
|||||||
await nc_client.notes.delete_note(bob_note_id)
|
await nc_client.notes.delete_note(bob_note_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_oauth_mcp_clients_initialized(
|
async def test_oauth_mcp_clients_initialized(
|
||||||
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client
|
||||||
):
|
):
|
||||||
+77
-76
@@ -1,11 +1,14 @@
|
|||||||
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
|
"""Integration tests for OAuth scope-based authorization and dynamic tool filtering.
|
||||||
|
|
||||||
These tests verify:
|
These tests verify:
|
||||||
1. Dynamic tool filtering based on user's token scopes
|
1. Dynamic tool filtering based on user's token scopes (using JWT tokens)
|
||||||
2. Scope enforcement (403 responses for insufficient scopes)
|
2. Scope enforcement (403 responses for insufficient scopes)
|
||||||
3. Protected Resource Metadata (PRM) endpoint
|
3. Protected Resource Metadata (PRM) endpoint (RFC 9728)
|
||||||
4. WWW-Authenticate challenge headers
|
4. WWW-Authenticate challenge headers
|
||||||
5. BasicAuth bypass (all tools visible)
|
5. BasicAuth bypass (all tools visible)
|
||||||
|
|
||||||
|
Note: Tests use JWT OAuth tokens because scopes are embedded in the token payload,
|
||||||
|
enabling efficient scope-based tool filtering without additional API calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -25,8 +28,8 @@ async def test_prm_endpoint():
|
|||||||
|
|
||||||
prm_data = response.json()
|
prm_data = response.json()
|
||||||
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
assert prm_data["resource"] == "http://localhost:8001/mcp"
|
||||||
assert "nc:read" in prm_data["scopes_supported"]
|
assert "notes:read" in prm_data["scopes_supported"]
|
||||||
assert "nc:write" in prm_data["scopes_supported"]
|
assert "notes:write" in prm_data["scopes_supported"]
|
||||||
assert "http://localhost:8080" in prm_data["authorization_servers"]
|
assert "http://localhost:8080" in prm_data["authorization_servers"]
|
||||||
assert "header" in prm_data["bearer_methods_supported"]
|
assert "header" in prm_data["bearer_methods_supported"]
|
||||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||||
@@ -56,12 +59,12 @@ async def test_basicauth_shows_all_tools(nc_mcp_client):
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only):
|
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."""
|
"""Test that a token with only read scopes filters out write tools."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has only "nc:read" scope
|
# Connect with token that has only "notes:read" scope
|
||||||
result = await nc_mcp_oauth_client_read_only.list_tools()
|
result = await nc_mcp_oauth_client_read_only.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
@@ -69,29 +72,27 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
|||||||
tool_names = [tool.name for tool in result.tools]
|
tool_names = [tool.name for tool in result.tools]
|
||||||
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||||
|
|
||||||
# Verify read tools are present
|
# Verify read tools are present (only for apps with :read scopes)
|
||||||
|
# Read-only token has: notes:read, calendar:read, contacts:read,
|
||||||
|
# cookbook:read, deck:read, tables:read, files:read, sharing:read
|
||||||
expected_read_tools = [
|
expected_read_tools = [
|
||||||
"nc_notes_get_note",
|
"nc_notes_get_note", # notes:read
|
||||||
"nc_notes_search_notes",
|
"nc_notes_search_notes", # notes:read
|
||||||
"nc_calendar_list_calendars",
|
"nc_calendar_list_calendars", # calendar:read
|
||||||
"nc_calendar_get_event",
|
"nc_calendar_get_event", # calendar:read
|
||||||
"nc_webdav_list_directory",
|
|
||||||
"nc_webdav_read_file",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_read_tools:
|
for tool in expected_read_tools:
|
||||||
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
|
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
|
||||||
|
|
||||||
# Verify write tools are NOT present
|
# Verify write tools are NOT present (filtered out)
|
||||||
write_tools_should_be_filtered = [
|
write_tools_should_be_filtered = [
|
||||||
"nc_notes_create_note",
|
"nc_notes_create_note", # notes:write
|
||||||
"nc_notes_update_note",
|
"nc_notes_update_note", # notes:write
|
||||||
"nc_notes_delete_note",
|
"nc_notes_delete_note", # notes:write
|
||||||
"nc_calendar_create_event",
|
"nc_calendar_create_event", # calendar:write
|
||||||
"nc_calendar_update_event",
|
"nc_calendar_update_event", # calendar:write
|
||||||
"nc_calendar_delete_event",
|
"nc_calendar_delete_event", # calendar:write
|
||||||
"nc_webdav_write_file",
|
|
||||||
"nc_webdav_create_directory",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in write_tools_should_be_filtered:
|
for tool in write_tools_should_be_filtered:
|
||||||
@@ -107,12 +108,12 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only):
|
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."""
|
"""Test that a token with only write scopes filters out read tools."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has only "nc:write" scope
|
# Connect with token that has only "notes:write" scope
|
||||||
result = await nc_mcp_oauth_client_write_only.list_tools()
|
result = await nc_mcp_oauth_client_write_only.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
@@ -121,28 +122,26 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
|||||||
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||||
|
|
||||||
# Verify write tools are present
|
# Verify write tools are present
|
||||||
|
# Write-only token has: notes:write, calendar:write, contacts:write,
|
||||||
|
# cookbook:write, deck:write, tables:write, files:write, sharing:write
|
||||||
expected_write_tools = [
|
expected_write_tools = [
|
||||||
"nc_notes_create_note",
|
"nc_notes_create_note", # notes:write
|
||||||
"nc_notes_update_note",
|
"nc_notes_update_note", # notes:write
|
||||||
"nc_notes_delete_note",
|
"nc_notes_delete_note", # notes:write
|
||||||
"nc_calendar_create_event",
|
"nc_calendar_create_event", # calendar:write
|
||||||
"nc_calendar_update_event",
|
"nc_calendar_update_event", # calendar:write
|
||||||
"nc_calendar_delete_event",
|
"nc_calendar_delete_event", # calendar:write
|
||||||
"nc_webdav_write_file",
|
|
||||||
"nc_webdav_create_directory",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_write_tools:
|
for tool in expected_write_tools:
|
||||||
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
|
||||||
|
|
||||||
# Verify read tools are NOT present (write-only scope)
|
# Verify read-only tools are NOT present (write-only scope)
|
||||||
read_tools_should_be_filtered = [
|
read_tools_should_be_filtered = [
|
||||||
"nc_notes_get_note",
|
"nc_notes_get_note", # notes:read
|
||||||
"nc_notes_search_notes",
|
"nc_notes_search_notes", # notes:read
|
||||||
"nc_calendar_list_calendars",
|
"nc_calendar_list_calendars", # calendar:read
|
||||||
"nc_calendar_get_event",
|
"nc_calendar_get_event", # calendar:read
|
||||||
"nc_webdav_list_directory",
|
|
||||||
"nc_webdav_read_file",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in read_tools_should_be_filtered:
|
for tool in read_tools_should_be_filtered:
|
||||||
@@ -158,31 +157,31 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access):
|
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."""
|
"""Test that a token with both read and write scopes scopes can see all tools."""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Connect with token that has both "nc:read" and "nc:write" scopes
|
# Connect with token that has both "notes:read" and "notes:write" scopes
|
||||||
result = await nc_mcp_oauth_client_full_access.list_tools()
|
result = await nc_mcp_oauth_client_full_access.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
tool_names = [tool.name for tool in result.tools]
|
tool_names = [tool.name for tool in result.tools]
|
||||||
logger.info(f"Full access token sees {len(tool_names)} tools")
|
logger.info(f"Full access token sees {len(tool_names)} tools")
|
||||||
|
logger.info(f"Tools: {sorted(tool_names)}")
|
||||||
|
|
||||||
# Verify both read and write tools are present
|
# Verify both read and write tools are present
|
||||||
|
# Full access has all *read and *write scopes
|
||||||
expected_read_tools = [
|
expected_read_tools = [
|
||||||
"nc_notes_get_note",
|
"nc_notes_get_note", # notes:read
|
||||||
"nc_notes_search_notes",
|
"nc_notes_search_notes", # notes:read
|
||||||
"nc_calendar_list_calendars",
|
"nc_calendar_list_calendars", # calendar:read
|
||||||
"nc_webdav_read_file",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
expected_write_tools = [
|
expected_write_tools = [
|
||||||
"nc_notes_create_note",
|
"nc_notes_create_note", # notes:write
|
||||||
"nc_calendar_create_event",
|
"nc_calendar_create_event", # calendar:write
|
||||||
"nc_webdav_write_file",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_read_tools:
|
for tool in expected_read_tools:
|
||||||
@@ -215,17 +214,17 @@ async def test_scope_helper_functions():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Add scope metadata
|
# Add scope metadata
|
||||||
mock_read_tool._required_scopes = ["nc:read"] # type: ignore
|
mock_read_tool._required_scopes = ["notes:read"] # type: ignore
|
||||||
mock_write_tool._required_scopes = ["nc:write"] # type: ignore
|
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
|
||||||
|
|
||||||
# Test get_required_scopes
|
# Test get_required_scopes
|
||||||
assert get_required_scopes(mock_read_tool) == ["nc:read"]
|
assert get_required_scopes(mock_read_tool) == ["notes:read"]
|
||||||
assert get_required_scopes(mock_write_tool) == ["nc:write"]
|
assert get_required_scopes(mock_write_tool) == ["notes:write"]
|
||||||
assert get_required_scopes(mock_no_scope_tool) == []
|
assert get_required_scopes(mock_no_scope_tool) == []
|
||||||
|
|
||||||
# Test has_required_scopes
|
# Test has_required_scopes
|
||||||
read_only_scopes = {"nc:read"}
|
read_only_scopes = {"notes:read"}
|
||||||
full_scopes = {"nc:read", "nc:write"}
|
full_scopes = {"notes:read", "notes:write"}
|
||||||
no_scopes = set()
|
no_scopes = set()
|
||||||
|
|
||||||
# User with only read scope
|
# User with only read scope
|
||||||
@@ -249,13 +248,13 @@ async def test_scope_decorator_stores_metadata():
|
|||||||
"""Test that @require_scopes decorator properly stores metadata."""
|
"""Test that @require_scopes decorator properly stores metadata."""
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
|
||||||
@require_scopes("nc:read", "nc:write")
|
@require_scopes("notes:read", "notes:write")
|
||||||
async def test_function():
|
async def test_function():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check that metadata was stored
|
# Check that metadata was stored
|
||||||
assert hasattr(test_function, "_required_scopes")
|
assert hasattr(test_function, "_required_scopes")
|
||||||
assert test_function._required_scopes == ["nc:read", "nc:write"]
|
assert test_function._required_scopes == ["notes:read", "notes:write"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -299,36 +298,38 @@ async def test_tools_have_scope_decorators(nc_mcp_client):
|
|||||||
assert tool in tool_names, f"Expected write tool {tool} not found"
|
assert tool in tool_names, f"Expected write tool {tool} not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_scope_classification():
|
async def test_scope_classification():
|
||||||
"""Test that our scope classification correctly identifies read vs write operations."""
|
"""Test that our scope classification correctly identifies read vs write operations."""
|
||||||
from scripts.add_scope_decorators_simple import classify_function
|
from scripts.add_scope_decorators_simple import classify_function
|
||||||
|
|
||||||
# Test read operations
|
# Test read operations
|
||||||
assert classify_function("nc_notes_get_note") == "nc:read"
|
assert classify_function("nc_notes_get_note") == "notes:read"
|
||||||
assert classify_function("nc_notes_search_notes") == "nc:read"
|
assert classify_function("nc_notes_search_notes") == "notes:read"
|
||||||
assert classify_function("nc_calendar_list_events") == "nc:read"
|
assert classify_function("nc_calendar_list_events") == "calendar:read"
|
||||||
assert classify_function("nc_webdav_read_file") == "nc:read"
|
assert classify_function("nc_webdav_read_file") == "files:read"
|
||||||
assert classify_function("nc_calendar_find_availability") == "nc:read"
|
assert classify_function("nc_calendar_find_availability") == "calendar:read"
|
||||||
assert classify_function("nc_calendar_get_upcoming_events") == "nc:read"
|
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
|
||||||
|
|
||||||
# Test write operations
|
# Test write operations
|
||||||
assert classify_function("nc_notes_create_note") == "nc:write"
|
assert classify_function("nc_notes_create_note") == "notes:write"
|
||||||
assert classify_function("nc_notes_update_note") == "nc:write"
|
assert classify_function("nc_notes_update_note") == "notes:write"
|
||||||
assert classify_function("nc_notes_delete_note") == "nc:write"
|
assert classify_function("nc_notes_delete_note") == "notes:write"
|
||||||
assert classify_function("nc_notes_append_content") == "nc:write"
|
assert classify_function("nc_notes_append_content") == "notes:write"
|
||||||
assert classify_function("nc_calendar_create_event") == "nc:write"
|
assert classify_function("nc_calendar_create_event") == "calendar:write"
|
||||||
assert classify_function("nc_calendar_update_event") == "nc:write"
|
assert classify_function("nc_calendar_update_event") == "notes:write"
|
||||||
assert classify_function("nc_calendar_manage_calendar") == "nc:write"
|
assert classify_function("nc_calendar_manage_calendar") == "notes:write"
|
||||||
assert classify_function("nc_webdav_write_file") == "nc:write"
|
assert classify_function("nc_webdav_write_file") == "files:write"
|
||||||
assert classify_function("nc_webdav_move_resource") == "nc:write"
|
assert classify_function("nc_webdav_move_resource") == "notes:write"
|
||||||
assert classify_function("nc_contacts_create_contact") == "nc:write"
|
assert classify_function("nc_contacts_create_contact") == "notes:write"
|
||||||
assert classify_function("nc_cookbook_import_recipe") == "nc:write"
|
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
|
||||||
assert classify_function("nc_tables_insert_row") == "nc:write"
|
assert classify_function("nc_tables_insert_row") == "notes:write"
|
||||||
assert classify_function("deck_archive_card") == "nc:write"
|
assert classify_function("deck_archive_card") == "notes:write"
|
||||||
assert classify_function("deck_assign_label_to_card") == "nc:write"
|
assert classify_function("deck_assign_label_to_card") == "notes:write"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
async def test_all_tools_classified():
|
async def test_all_tools_classified():
|
||||||
"""Verify that all tools can be properly classified as read or write."""
|
"""Verify that all tools can be properly classified as read or write."""
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ async def test_mcp_cookbook_update_recipe(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 4. Verify update via direct NextcloudClient
|
# 4. Verify update via direct NextcloudClient
|
||||||
await asyncio.sleep(1) # Allow propagation
|
await anyio.sleep(1) # Allow propagation
|
||||||
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
updated_recipe = await nc_client.cookbook.get_recipe(created_recipe_id)
|
||||||
assert updated_recipe["description"] == "Updated via MCP"
|
assert updated_recipe["description"] == "Updated via MCP"
|
||||||
assert len(updated_recipe["recipeIngredient"]) == 2
|
assert len(updated_recipe["recipeIngredient"]) == 2
|
||||||
@@ -282,7 +282,7 @@ async def test_mcp_cookbook_search_recipes(
|
|||||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||||
|
|
||||||
# 2. Allow time for indexing
|
# 2. Allow time for indexing
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# 3. Search for the recipe via MCP
|
# 3. Search for the recipe via MCP
|
||||||
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
logger.info(f"Searching for recipes via MCP with keyword: {unique_keyword}")
|
||||||
@@ -358,7 +358,7 @@ async def test_mcp_cookbook_categories_workflow(
|
|||||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||||
|
|
||||||
# 2. Allow time for indexing
|
# 2. Allow time for indexing
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# 3. List categories via MCP
|
# 3. List categories via MCP
|
||||||
logger.info("Listing categories via MCP")
|
logger.info("Listing categories via MCP")
|
||||||
@@ -433,9 +433,9 @@ async def test_mcp_cookbook_keywords_workflow(
|
|||||||
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
created_recipe_id = await nc_client.cookbook.create_recipe(recipe_data)
|
||||||
|
|
||||||
# 2. Allow extra time for indexing and trigger reindex
|
# 2. Allow extra time for indexing and trigger reindex
|
||||||
await asyncio.sleep(3)
|
await anyio.sleep(3)
|
||||||
await nc_client.cookbook.reindex()
|
await nc_client.cookbook.reindex()
|
||||||
await asyncio.sleep(2)
|
await anyio.sleep(2)
|
||||||
|
|
||||||
# 3. List keywords via MCP
|
# 3. List keywords via MCP
|
||||||
logger.info("Listing keywords via MCP")
|
logger.info("Listing keywords via MCP")
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
"""
|
|
||||||
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"])
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mcp_oauth_server_connection(nc_mcp_oauth_client):
|
|
||||||
"""Test connection to OAuth-enabled MCP server."""
|
|
||||||
result = await nc_mcp_oauth_client.list_tools()
|
|
||||||
assert result is not None
|
|
||||||
assert len(result.tools) > 0
|
|
||||||
|
|
||||||
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client):
|
|
||||||
"""Test executing a tool on the OAuth-enabled MCP server."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Example: Execute the 'nc_notes_search_notes' tool
|
|
||||||
result = await nc_mcp_oauth_client.call_tool(
|
|
||||||
"nc_notes_search_notes", arguments={"query": ""}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
|
||||||
assert result.content is not None
|
|
||||||
response_data = json.loads(result.content[0].text)
|
|
||||||
|
|
||||||
# The search response should have a 'results' field containing the list
|
|
||||||
assert "results" in response_data
|
|
||||||
assert isinstance(response_data["results"], list)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client):
|
|
||||||
"""Test that MCP OAuth client via Playwright can execute tools."""
|
|
||||||
|
|
||||||
# Test: Execute the 'nc_notes_search_notes' tool
|
|
||||||
result = await nc_mcp_oauth_client.call_tool(
|
|
||||||
"nc_notes_search_notes", arguments={"query": ""}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
|
||||||
assert result.content is not None
|
|
||||||
response_data = json.loads(result.content[0].text)
|
|
||||||
|
|
||||||
# The search response should have a 'results' field containing the list
|
|
||||||
assert "results" in response_data
|
|
||||||
assert isinstance(response_data["results"], list)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes."
|
|
||||||
)
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
"""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."
|
|
||||||
)
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
||||||
"""Test creating a user and verifying deletion (cleanup by fixture)."""
|
"""Test creating a user and verifying deletion (cleanup by fixture)."""
|
||||||
user_config = test_user
|
user_config = test_user
|
||||||
@@ -29,7 +26,6 @@ async def test_create_and_delete_user(nc_client: NextcloudClient, test_user):
|
|||||||
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
# Note: Fixture cleanup will also try to delete but handle 404 gracefully
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
||||||
"""Test updating user fields."""
|
"""Test updating user fields."""
|
||||||
user_config = test_user
|
user_config = test_user
|
||||||
@@ -44,7 +40,6 @@ async def test_update_user_field(nc_client: NextcloudClient, test_user):
|
|||||||
# Fixture will handle cleanup
|
# Fixture will handle cleanup
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
||||||
"""Test adding and removing users from groups."""
|
"""Test adding and removing users from groups."""
|
||||||
user_config, groupid = test_user_in_group
|
user_config, groupid = test_user_in_group
|
||||||
@@ -61,7 +56,6 @@ async def test_user_groups(nc_client: NextcloudClient, test_user_in_group):
|
|||||||
# Fixtures will handle cleanup
|
# Fixtures will handle cleanup
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
|
async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group):
|
||||||
"""Test promoting and demoting subadmins."""
|
"""Test promoting and demoting subadmins."""
|
||||||
user_config = test_user
|
user_config = test_user
|
||||||
@@ -82,7 +76,6 @@ async def test_user_subadmins(nc_client: NextcloudClient, test_user, test_group)
|
|||||||
# Fixtures will handle cleanup
|
# Fixtures will handle cleanup
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
||||||
"""Test disabling and enabling users."""
|
"""Test disabling and enabling users."""
|
||||||
user_config = test_user
|
user_config = test_user
|
||||||
@@ -102,7 +95,6 @@ async def test_disable_enable_user(nc_client: NextcloudClient, test_user):
|
|||||||
# Fixture will handle cleanup
|
# Fixture will handle cleanup
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_get_editable_user_fields(nc_client: NextcloudClient):
|
async def test_get_editable_user_fields(nc_client: NextcloudClient):
|
||||||
editable_fields = await nc_client.users.get_editable_user_fields()
|
editable_fields = await nc_client.users.get_editable_user_fields()
|
||||||
assert "displayname" in editable_fields
|
assert "displayname" in editable_fields
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Critical path smoke tests for quick validation."""
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Smoke tests - critical path tests for quick validation.
|
||||||
|
|
||||||
|
These tests verify the most essential functionality:
|
||||||
|
- MCP server connectivity
|
||||||
|
- Basic CRUD operations for core apps
|
||||||
|
- OAuth authentication
|
||||||
|
- Tool schema validation
|
||||||
|
|
||||||
|
Run with: uv run pytest -m smoke -v
|
||||||
|
Expected runtime: ~30-60 seconds
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mcp_connectivity_smoke(nc_mcp_client):
|
||||||
|
"""Smoke test: Verify MCP server is reachable and lists tools."""
|
||||||
|
tools = await nc_mcp_client.list_tools()
|
||||||
|
|
||||||
|
# Should have a reasonable number of tools
|
||||||
|
assert len(tools.tools) > 30, f"Expected >30 tools, got {len(tools.tools)}"
|
||||||
|
|
||||||
|
# Check for core tool categories
|
||||||
|
tool_names = [tool.name for tool in tools.tools]
|
||||||
|
assert any("notes" in name for name in tool_names), "Missing notes tools"
|
||||||
|
assert any("calendar" in name for name in tool_names), "Missing calendar tools"
|
||||||
|
assert any("webdav" in name for name in tool_names), "Missing webdav tools"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_notes_crud_smoke(nc_mcp_client, nc_client):
|
||||||
|
"""Smoke test: Verify basic Notes CRUD operations work."""
|
||||||
|
# Create
|
||||||
|
create_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_notes_create_note",
|
||||||
|
arguments={
|
||||||
|
"title": "Smoke Test Note",
|
||||||
|
"content": "Testing basic CRUD",
|
||||||
|
"category": "test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_result.isError is False
|
||||||
|
data = json.loads(create_result.content[0].text)
|
||||||
|
note_id = data["id"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read
|
||||||
|
get_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_notes_get_note",
|
||||||
|
arguments={"note_id": note_id},
|
||||||
|
)
|
||||||
|
assert get_result.isError is False
|
||||||
|
|
||||||
|
# Update
|
||||||
|
update_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_notes_update_note",
|
||||||
|
arguments={
|
||||||
|
"note_id": note_id,
|
||||||
|
"title": "Updated Smoke Test",
|
||||||
|
"content": "Updated content",
|
||||||
|
"category": "test",
|
||||||
|
"etag": data["etag"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert update_result.isError is False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Delete
|
||||||
|
delete_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_notes_delete_note",
|
||||||
|
arguments={"note_id": note_id},
|
||||||
|
)
|
||||||
|
assert delete_result.isError is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_calendar_basic_smoke(nc_mcp_client):
|
||||||
|
"""Smoke test: Verify calendar operations work."""
|
||||||
|
# List calendars
|
||||||
|
result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_calendar_list_calendars",
|
||||||
|
arguments={},
|
||||||
|
)
|
||||||
|
assert result.isError is False
|
||||||
|
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
assert "calendars" in data
|
||||||
|
assert len(data["calendars"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_webdav_basic_smoke(nc_mcp_client):
|
||||||
|
"""Smoke test: Verify WebDAV file operations work."""
|
||||||
|
# List root directory
|
||||||
|
result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_webdav_list_directory",
|
||||||
|
arguments={"path": "/"},
|
||||||
|
)
|
||||||
|
assert result.isError is False
|
||||||
|
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
assert "files" in data
|
||||||
|
assert isinstance(data["files"], list)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.oauth
|
||||||
|
async def test_oauth_connectivity_smoke(nc_mcp_oauth_client):
|
||||||
|
"""Smoke test: Verify OAuth authentication works."""
|
||||||
|
# List tools with OAuth
|
||||||
|
result = await nc_mcp_oauth_client.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
# Execute a simple tool
|
||||||
|
search_result = await nc_mcp_oauth_client.call_tool(
|
||||||
|
"nc_notes_search_notes",
|
||||||
|
arguments={"query": ""},
|
||||||
|
)
|
||||||
|
assert search_result.isError is False
|
||||||
+2
-1
@@ -254,7 +254,8 @@ def test_default_values(runner, clean_env, monkeypatch):
|
|||||||
|
|
||||||
# Verify default values
|
# Verify default values
|
||||||
assert (
|
assert (
|
||||||
captured_env["NEXTCLOUD_OIDC_SCOPES"] == "openid profile email nc:read nc:write"
|
captured_env["NEXTCLOUD_OIDC_SCOPES"]
|
||||||
|
== "openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
||||||
)
|
)
|
||||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
||||||
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests with mocked dependencies for fast feedback."""
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Unit tests for Pydantic response models."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.notes import (
|
||||||
|
CreateNoteResponse,
|
||||||
|
Note,
|
||||||
|
NoteSearchResult,
|
||||||
|
SearchNotesResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_note_model_creation():
|
||||||
|
"""Test creating a Note model with required fields."""
|
||||||
|
note = Note(
|
||||||
|
id=123,
|
||||||
|
title="Test Note",
|
||||||
|
content="# Test Content",
|
||||||
|
modified=1700000000,
|
||||||
|
etag="abc123",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert note.id == 123
|
||||||
|
assert note.title == "Test Note"
|
||||||
|
assert note.content == "# Test Content"
|
||||||
|
assert note.category == "" # default value
|
||||||
|
assert note.favorite is False # default value
|
||||||
|
assert note.etag == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_note_modified_datetime_property():
|
||||||
|
"""Test that Note.modified_datetime converts Unix timestamp correctly."""
|
||||||
|
note = Note(
|
||||||
|
id=1,
|
||||||
|
title="Test",
|
||||||
|
content="Content",
|
||||||
|
modified=1700000000,
|
||||||
|
etag="etag",
|
||||||
|
)
|
||||||
|
|
||||||
|
dt = note.modified_datetime
|
||||||
|
assert dt.year == 2023 # Nov 14, 2023
|
||||||
|
assert dt.month == 11
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_create_note_response_serialization():
|
||||||
|
"""Test CreateNoteResponse can serialize to JSON."""
|
||||||
|
response = CreateNoteResponse(
|
||||||
|
id=42,
|
||||||
|
title="New Note",
|
||||||
|
category="Work",
|
||||||
|
etag="xyz789",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test serialization
|
||||||
|
data = response.model_dump()
|
||||||
|
assert data["id"] == 42
|
||||||
|
assert data["title"] == "New Note"
|
||||||
|
assert data["category"] == "Work"
|
||||||
|
assert data["etag"] == "xyz789"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_search_notes_response_wraps_results():
|
||||||
|
"""Test SearchNotesResponse wraps list of results correctly.
|
||||||
|
|
||||||
|
This is critical - FastMCP mangles raw List[Dict] responses,
|
||||||
|
so we must wrap them in a response model.
|
||||||
|
"""
|
||||||
|
results = [
|
||||||
|
NoteSearchResult(id=1, title="First Note", category="Work"),
|
||||||
|
NoteSearchResult(id=2, title="Second Note", category="Personal"),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = SearchNotesResponse(
|
||||||
|
results=results,
|
||||||
|
query="test query",
|
||||||
|
total_found=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the response structure
|
||||||
|
assert len(response.results) == 2
|
||||||
|
assert response.results[0].id == 1
|
||||||
|
assert response.results[1].title == "Second Note"
|
||||||
|
assert response.query == "test query"
|
||||||
|
assert response.total_found == 2
|
||||||
|
|
||||||
|
# Verify it serializes correctly
|
||||||
|
data = response.model_dump()
|
||||||
|
assert "results" in data
|
||||||
|
assert isinstance(data["results"], list)
|
||||||
|
assert len(data["results"]) == 2
|
||||||
|
assert data["results"][0]["id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_note_search_result_with_score():
|
||||||
|
"""Test NoteSearchResult with optional score field."""
|
||||||
|
result = NoteSearchResult(
|
||||||
|
id=99,
|
||||||
|
title="Relevant Note",
|
||||||
|
category="Archive",
|
||||||
|
score=0.95,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.id == 99
|
||||||
|
assert result.score == 0.95
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_note_search_result_without_score():
|
||||||
|
"""Test NoteSearchResult without optional score field."""
|
||||||
|
result = NoteSearchResult(
|
||||||
|
id=99,
|
||||||
|
title="Relevant Note",
|
||||||
|
category="Archive",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.id == 99
|
||||||
|
assert result.score is None
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unit tests for scope decorator metadata and classification logic."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||||
|
InsufficientScopeError,
|
||||||
|
require_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_scope_decorator_stores_metadata():
|
||||||
|
"""Test that @require_scopes decorator stores scope requirements as function metadata."""
|
||||||
|
|
||||||
|
@require_scopes("notes:read", "notes:write")
|
||||||
|
async def example_function():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Verify metadata is stored
|
||||||
|
assert hasattr(example_function, "_required_scopes")
|
||||||
|
assert example_function._required_scopes == ["notes:read", "notes:write"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_scope_decorator_with_single_scope():
|
||||||
|
"""Test decorator with a single scope requirement."""
|
||||||
|
|
||||||
|
@require_scopes("calendar:read")
|
||||||
|
async def example_function():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert example_function._required_scopes == ["calendar:read"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_scope_decorator_with_no_scopes():
|
||||||
|
"""Test decorator with no scope requirements."""
|
||||||
|
|
||||||
|
@require_scopes()
|
||||||
|
async def example_function():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert example_function._required_scopes == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_insufficient_scope_error():
|
||||||
|
"""Test InsufficientScopeError exception structure."""
|
||||||
|
missing = ["notes:write", "calendar:write"]
|
||||||
|
error = InsufficientScopeError(missing)
|
||||||
|
|
||||||
|
assert error.missing_scopes == missing
|
||||||
|
assert "notes:write" in str(error)
|
||||||
|
assert "calendar:write" in str(error)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_insufficient_scope_error_with_custom_message():
|
||||||
|
"""Test InsufficientScopeError with custom message."""
|
||||||
|
missing = ["files:write"]
|
||||||
|
custom_msg = "You need more permissions"
|
||||||
|
error = InsufficientScopeError(missing, custom_msg)
|
||||||
|
|
||||||
|
assert error.missing_scopes == missing
|
||||||
|
assert str(error) == custom_msg
|
||||||
Vendored
+1
-1
Submodule third_party/oidc updated: 0842fad479...515ae3a8cf
@@ -952,6 +952,7 @@ dev = [
|
|||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
{ name = "pytest-playwright-asyncio" },
|
{ name = "pytest-playwright-asyncio" },
|
||||||
{ name = "pytest-timeout" },
|
{ name = "pytest-timeout" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
@@ -977,6 +978,7 @@ dev = [
|
|||||||
{ name = "playwright", specifier = ">=1.49.1" },
|
{ name = "playwright", specifier = ">=1.49.1" },
|
||||||
{ name = "pytest", specifier = ">=8.3.5" },
|
{ name = "pytest", specifier = ">=8.3.5" },
|
||||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||||
|
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||||
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
|
{ name = "pytest-playwright-asyncio", specifier = ">=0.7.1" },
|
||||||
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
{ name = "pytest-timeout", specifier = ">=2.3.1" },
|
||||||
{ name = "ruff", specifier = ">=0.11.13" },
|
{ name = "ruff", specifier = ">=0.11.13" },
|
||||||
@@ -1379,6 +1381,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest-playwright-asyncio"
|
name = "pytest-playwright-asyncio"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user