Merge remote-tracking branch 'origin/master' into docs/adr-024-dynaconf-config-management

This commit is contained in:
Chris Coutinho
2026-04-07 14:17:57 +02:00
48 changed files with 733 additions and 525 deletions
+2 -2
View File
@@ -50,10 +50,10 @@ jobs:
- nextcloud_version: "31"
nextcloud_image: "docker.io/library/nextcloud:31.0.14@sha256:9bf3fae91aad4dca3eff02c1f71df8d5c6705a349065fb537aa5c5ef578f1013"
- nextcloud_version: "32"
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
nextcloud_image: "docker.io/library/nextcloud:32.0.8@sha256:509caed16832edc659911b19ee6f347b8105a065a416089b6e187badf2241c90"
# Disabled until all upstream apps support NC 33
# - nextcloud_version: "33"
# nextcloud_image: "docker.io/library/nextcloud:33.0.1@sha256:0b14848e05d19aa25e8a189eb9cfef4215489e0cbf5eb999e8784bf03027c226"
# nextcloud_image: "docker.io/library/nextcloud:33.0.2@sha256:661284cad636e037f31904de990b3c5279154b97b61db27a54a2957702c6e373"
# Mode-specific properties
- mode: single-user
+1 -1
View File
@@ -373,7 +373,7 @@ MCP sampling allows servers to request LLM completions from their clients. This
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
@mcp.tool()
@require_scopes("notes:read")
@require_scopes("notes.read")
async def nc_notes_semantic_search_answer(
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
) -> SamplingSearchResponse:
@@ -40,7 +40,7 @@ CLIENT_ID="astrolabeMcpClientOAuth00000000000"
REDIRECT_URI="${NC_EXTERNAL_URL}/apps/astrolabe/oauth/callback"
# All scopes the MCP server supports (must match DCR scopes in app.py)
ALLOWED_SCOPES="openid profile email offline_access 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 news:read news:write collectives:read collectives:write semantic:read"
ALLOWED_SCOPES="openid profile email offline_access 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 news.read news.write collectives.read collectives.write semantic.read"
# Create OAuth client
CLIENT_JSON=$(php occ oidc:create "Astrolabe" \
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.58.28"
version = "0.58.30"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
+4
View File
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.58.30 (2026-04-07)
## nextcloud-mcp-server-0.58.29 (2026-04-07)
## nextcloud-mcp-server-0.58.28 (2026-04-05)
### Fix
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.58.28
version: 0.58.30
appVersion: "0.68.3"
keywords:
- nextcloud
+2 -2
View File
@@ -84,7 +84,7 @@ auth:
clientId: ""
clientSecret: ""
# OAuth scopes to request (space-separated)
scopes: "openid profile email offline_access 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"
scopes: "openid profile email offline_access 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"
# Use existing secret for multi-user basic auth credentials
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
# Secret should contain keys specified in the *Key fields below
@@ -118,7 +118,7 @@ auth:
# Pre-registered OAuth client secret (optional, ignored if existingSecret is set)
clientSecret: ""
# OAuth scopes to request (space-separated)
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"
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"
# Use existing secret for OAuth client credentials
# If set, clientId and clientSecret above are ignored
# Secret must contain keys specified in clientIdKey and clientSecretKey
+2 -2
View File
@@ -23,7 +23,7 @@ services:
restart: always
app:
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.8@sha256:30aa31f1ef08974254114bef99845de79c3f5f00306d1d09b6211707e7d4c3e3}
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.8@sha256:509caed16832edc659911b19ee6f347b8105a065a416089b6e187badf2241c90}
restart: always
ports:
- 127.0.0.1:8080:80
@@ -237,7 +237,7 @@ services:
- ENABLE_DCR=true
# OAuth scopes (optional - uses defaults if not specified)
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access 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
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access 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 admin credentials - using external IdP OAuth only!
volumes:
+139
View File
@@ -0,0 +1,139 @@
# ADR-024: OAuth Scope Separator Migration (colon to dot)
**Status:** Accepted
**Date:** 2026-04-07
**Supersedes:** Scope naming conventions in ADR-004, ADR-009, ADR-011
## Context
The MCP server defines application-level OAuth scopes using the `resource:action`
pattern (e.g., `notes:read`, `calendar:write`). While the colon separator is
visually intuitive and used by some OAuth implementations, it causes
interoperability problems with many identity providers.
### IDP Compatibility Issues
Several widely-deployed identity providers reject or mishandle colons in OAuth
scope names:
- **Keycloak**: Accepts colons but requires special configuration for scope
mappers; colons can conflict with realm-qualified scope names
- **Auth0**: Permits colons but treats them as namespace delimiters in their
Resource Server API, leading to unexpected scope resolution behavior
- **Azure AD / Entra ID**: Uses colons internally for delegated permissions
(e.g., `User.Read`) and may reject custom scopes containing colons
- **AWS Cognito**: Restricts scope names to alphanumeric characters, hyphens,
periods, and underscores — colons are not allowed
- **Okta**: Custom scopes are restricted to `[a-zA-Z0-9._-]`; colons are
explicitly rejected
### RFC References
The OAuth 2.0 framework (RFC 6749, Section 3.3) defines scope values as:
> scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
This technically permits the colon character (`%x3A`), so colons are
spec-compliant. However, the specification also notes:
> The authorization server MAY fully or partially ignore the scope requested by
> the client, based on the authorization server policy or the resource owner's
> instructions.
In practice, many authorization servers impose stricter character restrictions
than the RFC minimum. The dot separator (`.`) is universally accepted across all
major OAuth/OIDC implementations and is the de facto convention used by:
- Microsoft Identity Platform (`User.Read`, `Mail.Send`)
- Google OAuth (`https://www.googleapis.com/auth/calendar.readonly`)
- MCP specification examples in RFC 9728 (OAuth Protected Resource Metadata)
### RFC 9728 (OAuth Protected Resource Metadata)
RFC 9728 defines the Protected Resource Metadata endpoint used by this server
(`/.well-known/oauth-protected-resource`). While the RFC does not mandate a
specific scope naming convention, its examples and the broader OAuth ecosystem
favor dot-separated scopes for maximum interoperability.
## Decision
Replace the colon (`:`) separator with a dot (`.`) in all application-level
OAuth scope names:
| Before | After |
|--------|-------|
| `notes:read` | `notes.read` |
| `notes:write` | `notes.write` |
| `calendar:read` | `calendar.read` |
| `calendar:write` | `calendar.write` |
| `todo:read` | `todo.read` |
| `todo:write` | `todo.write` |
| `contacts:read` | `contacts.read` |
| `contacts:write` | `contacts.write` |
| `files:read` | `files.read` |
| `files:write` | `files.write` |
| `tables:read` | `tables.read` |
| `tables:write` | `tables.write` |
| `deck:read` | `deck.read` |
| `deck:write` | `deck.write` |
| `cookbook:read` | `cookbook.read` |
| `cookbook:write` | `cookbook.write` |
| `sharing:read` | `sharing.read` |
| `sharing:write` | `sharing.write` |
| `news:read` | `news.read` |
| `news:write` | `news.write` |
| `collectives:read` | `collectives.read` |
| `collectives:write` | `collectives.write` |
| `semantic:read` | `semantic.read` |
Standard OIDC scopes (`openid`, `profile`, `email`, `offline_access`) are
unchanged — they are defined by OIDC Core and do not use separators.
## Consequences
### Positive
- **Universal IDP compatibility**: Dot-separated scopes work with every major
identity provider without special configuration
- **Industry alignment**: Matches the naming convention used by Microsoft,
Google, and other major OAuth implementations
- **No logic changes**: The authorization system uses string comparison and
`startswith()` prefix matching — changing the separator character requires no
algorithmic changes
### Negative
- **Breaking change**: Existing OAuth clients, stored tokens, and IDP
configurations must be updated to use the new scope names
- **Migration required**: An Alembic database migration updates stored scope
strings in `app_passwords` and `login_flow_sessions` tables
### Migration
- **Database**: Alembic migration `004` handles `REPLACE(scopes, ':', '.')`
on stored scope JSON
- **Keycloak**: The realm export (`keycloak/realm-export.json`) has been updated;
existing Keycloak deployments must re-import or manually update scope
definitions
- **Nextcloud OIDC app**: The `astrolabe` OAuth client hook
(`26-configure-astrolabe-oauth.sh`) has been updated with new scope names
- **Existing MCP clients**: Must update their scope requests to use dot
separators; old colon-separated scope requests will be rejected
## Alternatives Considered
### Hyphen separator (`notes-read`)
Rejected: While universally compatible, hyphens are commonly used within scope
component names (e.g., hypothetical `file-share.read`), creating ambiguity about
which hyphen is the separator.
### Underscore separator (`notes_read`)
Rejected: Also universally compatible but less conventional in the OAuth
ecosystem. Dot is the dominant separator in industry practice.
### Keep colons with IDP-specific workarounds
Rejected: Requires per-IDP configuration, documentation, and ongoing maintenance.
The root cause is a poor separator choice, not an IDP deficiency.
+3 -3
View File
@@ -76,7 +76,7 @@ Both modes maintain the critical separation: **refresh tokens are never used for
- ✅ Generated **on-demand** during tool execution
-**Ephemeral** - used only for current operation
-**NOT stored** - discarded after use
-**Limited scopes** - only what tool needs (e.g., `notes:read` for search)
-**Limited scopes** - only what tool needs (e.g., `notes.read` for search)
-**Short-lived** - expires quickly (e.g., 5 minutes)
**Background Jobs (Offline Operations)**:
@@ -202,7 +202,7 @@ Tools use the same pattern regardless of token acquisition mode:
```python
@mcp.tool()
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
@require_scopes("notes.read") # Soft-scope enforced by MCP server, not Nextcloud
@require_provisioning
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content."""
@@ -333,7 +333,7 @@ When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ep
## Nextcloud Scope Limitation
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes.read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
This means:
- Token exchange provides audit and delegation benefits, not scope restriction
+43 -43
View File
@@ -28,7 +28,7 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
### Key Features
-**JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
-**Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
-**Custom Scopes** - `mcp:notes.read` and `mcp:notes.write` for read/write access control
-**Dynamic Tool Filtering** - Tools filtered based on user's token scopes
-**Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
-**Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
@@ -38,8 +38,8 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
| Scope | Description | Tool Count |
|-------|-------------|------------|
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
| `mcp:notes.read` | Read-only access to Nextcloud data | 36 tools |
| `mcp:notes.write` | Write access to create/modify/delete data | 54 tools |
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
"aud": "client_id",
"exp": 1234567890,
"iat": 1234567890,
"scope": "openid profile email mcp:notes:read mcp:notes:write",
"scope": "openid profile email mcp:notes.read mcp:notes.write",
"client_id": "...",
"jti": "..."
}
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
| Scope | Operations | Examples |
|-------|------------|----------|
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
| `mcp:notes.read` | Read-only access | Get notes, search files, list calendars, read contacts |
| `mcp:notes.write` | Write operations | Create notes, update events, delete files, modify contacts |
### Standard OIDC Scopes
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
**Full Access:**
```
openid profile email mcp:notes:read mcp:notes:write
openid profile email mcp:notes.read mcp:notes.write
```
**Read-Only:**
```
openid profile email mcp:notes:read
openid profile email mcp:notes.read
```
**No Custom Scopes (OIDC only):**
@@ -150,32 +150,32 @@ All 90 MCP tools are decorated with scope requirements:
```python
@mcp.tool()
@require_scopes("mcp:notes:read")
@require_scopes("mcp:notes.read")
async def nc_notes_get_note(note_id: int, ctx: Context):
"""Get a note by ID (requires mcp:notes:read scope)"""
"""Get a note by ID (requires mcp:notes.read scope)"""
...
@mcp.tool()
@require_scopes("mcp:notes:write")
@require_scopes("mcp:notes.write")
async def nc_notes_create_note(title: str, content: str, ctx: Context):
"""Create a note (requires mcp:notes:write scope)"""
"""Create a note (requires mcp:notes.write scope)"""
...
```
**Coverage:**
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes.read")`
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes.write")`
- ✅ 90/90 tools covered (100%)
### Dynamic Tool Filtering
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
**Token with `mcp:notes:read` only:**
**Token with `mcp:notes.read` only:**
- `list_tools()` returns 36 read-only tools
- Write tools are hidden from the tool list
**Token with `mcp:notes:write` only:**
**Token with `mcp:notes.write` only:**
- `list_tools()` returns 54 write-only tools
- 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
**Token with no custom scopes:**
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
- `list_tools()` returns 0 tools (all require `mcp:notes.read` or `mcp:notes.write`)
**BasicAuth mode:**
- `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/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
scope="mcp:notes:write",
scope="mcp:notes.write",
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
```
@@ -213,7 +213,7 @@ The server implements RFC 9728's Protected Resource Metadata endpoint:
```json
{
"resource": "http://localhost:8001/mcp",
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
"scopes_supported": ["mcp:notes.read", "mcp:notes.write"],
"authorization_servers": ["http://localhost:8080"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
@@ -250,7 +250,7 @@ mcp-oauth:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
volumes:
- oauth-client-storage:/app/.oauth # Persist DCR credentials
```
@@ -286,7 +286,7 @@ mcp-oauth:
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes: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"` |
### Dynamic Client Registration (DCR)
@@ -320,7 +320,7 @@ DCR automatically configures the client based on environment variables:
# Minimal DCR configuration (no credentials needed!)
export NEXTCLOUD_HOST=http://localhost:8080
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes.read mcp:notes.write"
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
```
@@ -362,7 +362,7 @@ Manual client creation is **optional** but may be preferred when:
```bash
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
"Nextcloud MCP Server" \
"http://localhost:8000/oauth/callback"
```
@@ -373,7 +373,7 @@ docker compose exec app php occ oidc:create \
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
"token_type": "jwt",
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
"allowed_scopes": "openid profile email mcp:notes.read mcp:notes.write"
}
```
@@ -406,7 +406,7 @@ When credentials are provided via environment variables or storage file, **DCR i
│ │
│ JWT Access Token │
│ { │
│ "scope": "openid mcp:notes:read mcp:notes:write" │
│ "scope": "openid mcp:notes.read mcp:notes.write" │
│ ... │
│ } │
│ │
@@ -463,7 +463,7 @@ When credentials are provided via environment variables or storage file, **DCR i
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
- `GET /.well-known/oauth-protected-resource/mcp`
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
- Advertises `["mcp:notes.read", "mcp:notes.write"]`
- RFC 9728 compliant
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
@@ -499,7 +499,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
│ ├─ Authenticate with client credentials
│ ├─ Response contains:
│ │ • active: true/false
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
│ │ • scope: "openid mcp:notes.read mcp:notes.write"
│ │ • sub, exp, iat, client_id
│ ├─ Extract scopes from response
│ └─ Success: Return AccessToken
@@ -553,7 +553,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`)
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes: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
#### 2. Read-Only Access (36 tools)
@@ -561,7 +561,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
```
**Scenario:** JWT token with `mcp:notes:read` only
**Scenario:** JWT token with `mcp:notes.read` only
**Expected:** 36 read-only tools visible, write tools hidden
**Verifies:** Read tools accessible, write tools filtered out
@@ -570,7 +570,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
```
**Scenario:** JWT token with `mcp:notes:write` only
**Scenario:** JWT token with `mcp:notes.write` only
**Expected:** 54 write tools visible, read tools hidden
**Verifies:** Write tools accessible, read tools filtered out
@@ -579,21 +579,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
```
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
**Scenario:** JWT token with both `mcp:notes.read` and `mcp:notes.write`
**Expected:** All 90 tools visible
**Verifies:** Full access when user grants all custom scopes
### Test Fixtures
**OAuth Client Fixtures:**
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
- `read_only_oauth_client_credentials` - Client with `mcp:notes.read` only
- `write_only_oauth_client_credentials` - Client with `mcp:notes.write` only
- `full_access_oauth_client_credentials` - Client with both scopes
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
**Token Fixtures:**
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes.read`
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes.write`
- `playwright_oauth_token_full_access` - Obtains token with both scopes
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
@@ -681,14 +681,14 @@ docker compose exec app php occ oidc:list
# If empty, recreate client with --allowed_scopes
docker compose exec app php occ oidc:create \
--token_type=jwt \
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
"Client Name" \
"http://callback/url"
```
### Issue: All Tools Visible Despite Read-Only Token
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
**Symptom:** User with `mcp:notes.read` token can see all 90 tools including write tools
**Cause:** Server running in BasicAuth mode, not OAuth mode
@@ -716,7 +716,7 @@ DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provide
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
# Should show your requested scopes (e.g., "openid profile email mcp:notes.read mcp:notes.write")
```
**If scopes are missing:**
@@ -812,7 +812,7 @@ mcp-oauth:
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
ports:
- "8001:8001"
```
@@ -846,22 +846,22 @@ mcp-oauth:
```bash
# Success
INFO JWT verified successfully for user: admin
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes.read', 'mcp:notes.write'}
# Failures
WARNING JWT issuer validation failed: Invalid issuer
WARNING Missing required scopes: mcp:notes:write
WARNING Missing required scopes: mcp:notes.write
```
### Known Limitations
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes.read` and `mcp:notes.write` (not per-app scopes)
2. **No Refresh Token Support** - Tokens must be reacquired when expired
### Future Enhancements
**Potential Improvements:**
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
- Per-app scopes (`nc:notes.read`, `nc:calendar.write`)
- Resource-level filtering (apply to MCP resources, not just tools)
- Automatic scope discovery from decorated tools
- Admin UI for scope management
+43 -43
View File
@@ -89,7 +89,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
│ client_id=xxx │ │
│ &code_challenge=abc... │ │
│ &code_challenge_method=S256 │ │
│ &scope=openid notes:read ... │ │
│ &scope=openid notes.read ... │ │
│ │ │
│ 2c. User consent page │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
@@ -111,7 +111,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
│ 2g. Access token (JWT/opaque) │ │
│<─────────────────────────────────┼─────────────────────────────────────┤
│ {access_token, token_type, │ │
│ scope: "openid notes:read...") │ ← User's granted scopes │
│ scope: "openid notes.read...") │ ← User's granted scopes │
│ │ │
@@ -140,11 +140,11 @@ Phase 3: MCP Tool Access (Scope-based Authorization)
│ │ │
│ 3e. Call tool │ │
├─────────────────────────────────>│ │
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes.read") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 3f. Scope check PASSED │
│ │ ✓ Token has notes:read │
│ │ ✓ Token has notes.read │
│ │ │
│ │ 3g. Nextcloud API call │
│ ├────────────────────────────────────>│
@@ -168,17 +168,17 @@ Insufficient Scope Example (Step-Up Authorization)
│ 4a. Call write tool │ │
├─────────────────────────────────>│ │
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
│ nc_notes_create_note(...) │ ← @require_scopes("notes.write") │
│ Authorization: Bearer <token> │ │
│ │ │
│ │ 4b. Scope check FAILED │
│ │ ✗ Token only has notes:read │
│ │ ✗ Token only has notes.read │
│ │ │
│ 4c. 403 Insufficient Scope │ │
│<─────────────────────────────────┤ │
│ WWW-Authenticate: Bearer │ │
│ error="insufficient_scope", │ │
│ scope="notes:write", │ │
│ scope="notes.write", │ │
│ resource_metadata="..." │ │
│ │ │
│ → Client can re-authorize with │ │
@@ -364,7 +364,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
- `client_id`: OAuth client ID
- `code_challenge`: SHA256 hash of verifier
- `code_challenge_method`: `S256`
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
- `scope`: Requested scopes (e.g., `openid notes.read notes.write`)
- `redirect_uri`: MCP server callback URL
3. **User Consent**
@@ -409,7 +409,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
3. **Dynamic Tool Filtering**
- Server compares token scopes with each tool's `@require_scopes`
- Only returns tools where user has all required scopes
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
- Example: Token with `notes.read` sees 4 read tools, not 3 write tools
4. **Filtered Tool List**
- Client receives only tools they can use
@@ -420,7 +420,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
2. **Scope Validation**
- `@require_scopes` decorator extracts token scopes
- Verifies token contains required scope (e.g., `notes:read`)
- Verifies token contains required scope (e.g., `notes.read`)
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
- If present → continues execution
@@ -443,26 +443,26 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
**Steps**:
1. **Tool Call with Insufficient Scopes**
- User calls `nc_notes_create_note` (requires `notes:write`)
- But token only has `notes:read`
- User calls `nc_notes_create_note` (requires `notes.write`)
- But token only has `notes.read`
2. **Scope Validation Fails**
- `@require_scopes("notes:write")` decorator checks token
- Finds `notes:write` missing
- `@require_scopes("notes.write")` decorator checks token
- Finds `notes.write` missing
3. **403 Response with Challenge**
- Returns `403 Forbidden`
- Includes `WWW-Authenticate` header:
```
Bearer error="insufficient_scope",
scope="notes:write",
scope="notes.write",
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
```
4. **Client Re-Authorization** (Optional)
- Client can initiate new OAuth flow requesting additional scopes
- User re-consents with expanded permissions
- New token includes both `notes:read` and `notes:write`
- New token includes both `notes.read` and `notes.write`
**Result**: User can dynamically upgrade permissions without full re-authentication
@@ -599,46 +599,46 @@ The server supports the following OAuth scopes, organized by Nextcloud app:
- `email` - Access to user email address (required)
#### Notes App
- `notes:read` - Read notes, search notes, get note attachments
- `notes:write` - Create, update, append to, and delete notes
- `notes.read` - Read notes, search notes, get note attachments
- `notes.write` - Create, update, append to, and delete notes
#### Calendar App
- `calendar:read` - List calendars, read events, search events
- `calendar:write` - Create, update, and delete calendars and events
- `calendar.read` - List calendars, read events, search events
- `calendar.write` - Create, update, and delete calendars and events
#### Calendar Tasks (VTODO)
- `todo:read` - List and read CalDAV tasks
- `todo:write` - Create, update, and delete CalDAV tasks
- `todo.read` - List and read CalDAV tasks
- `todo.write` - Create, update, and delete CalDAV tasks
#### Contacts App
- `contacts:read` - List address books and read contacts (CardDAV)
- `contacts:write` - Create, update, and delete address books and contacts
- `contacts.read` - List address books and read contacts (CardDAV)
- `contacts.write` - Create, update, and delete address books and contacts
#### Cookbook App
- `cookbook:read` - Read recipes, search recipes
- `cookbook:write` - Create, update, and delete recipes
- `cookbook.read` - Read recipes, search recipes
- `cookbook.write` - Create, update, and delete recipes
#### Deck App
- `deck:read` - List boards, stacks, cards, and labels
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
- `deck.read` - List boards, stacks, cards, and labels
- `deck.write` - Create, update, and delete boards, stacks, cards, and labels
#### Tables App
- `tables:read` - List tables and read rows
- `tables:write` - Create, update, and delete rows in tables
- `tables.read` - List tables and read rows
- `tables.write` - Create, update, and delete rows in tables
#### Files (WebDAV)
- `files:read` - List files, read file contents, search files
- `files:write` - Upload, update, move, copy, and delete files
- `files.read` - List files, read file contents, search files
- `files.write` - Upload, update, move, copy, and delete files
#### Sharing
- `sharing:read` - List shares and read share information
- `sharing:write` - Create, update, and delete shares
- `sharing.read` - List shares and read share information
- `sharing.write` - Create, update, and delete shares
#### Semantic Search (Multi-App Vector Database)
- `semantic:read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
- `semantic:write` - Enable/disable background vector synchronization, manage indexing settings
- `semantic.read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
- `semantic.write` - Enable/disable background vector synchronization, manage indexing settings
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes:read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes.read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
### Scope Discovery
@@ -652,7 +652,7 @@ curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
# Response includes dynamically discovered scopes
{
"resource": "http://localhost:8000/mcp",
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
"scopes_supported": ["openid", "profile", "email", "notes.read", ...],
"authorization_servers": ["https://nextcloud.example.com"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"]
@@ -669,7 +669,7 @@ Tools are decorated with `@require_scopes()` to declare their required permissio
from nextcloud_mcp_server.auth import require_scopes
@mcp.tool()
@require_scopes("notes:read")
@require_scopes("notes.read")
async def nc_notes_get_note(ctx: Context, note_id: int):
"""Get a specific note by ID"""
# Implementation
@@ -681,7 +681,7 @@ During OAuth client registration (dynamic or manual), clients request a set of s
**Environment Variable**:
```bash
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
NEXTCLOUD_OIDC_SCOPES="openid profile email notes.read notes.write calendar.read calendar.write ..."
```
**Default**: All supported scopes (recommended for development)
@@ -695,7 +695,7 @@ The server supports OAuth step-up authorization (RFC 8693). If a user attempts t
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
2. Response includes `WWW-Authenticate` header listing missing scopes:
```
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes.write", resource_metadata="..."
```
3. Client can re-authorize with additional scopes
@@ -708,9 +708,9 @@ All scope enforcement happens at two levels:
**Example**:
```python
# User token has: ["openid", "profile", "email", "notes:read"]
# User token has: ["openid", "profile", "email", "notes.read"]
# They will see: 4 read-only notes tools
# They will NOT see: 3 write notes tools (notes:write required)
# They will NOT see: 3 write notes tools (notes.write required)
# Attempting to call a write tool returns 403 Forbidden
```
+10 -10
View File
@@ -412,7 +412,7 @@ http://localhost:8000/oauth/callback
**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`
- 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.
@@ -449,7 +449,7 @@ When reconnecting, you'll see a consent screen listing all available scopes. Mak
```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"
--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
```
@@ -463,14 +463,14 @@ curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_su
# 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
# - 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**:
+6 -6
View File
@@ -298,7 +298,7 @@ sequenceDiagram
participant NC as Nextcloud API
User->>MCP: nc_semantic_search("machine learning")
MCP->>MCP: Check OAuth scope<br/>(semantic:read)
MCP->>MCP: Check OAuth scope<br/>(semantic.read)
MCP->>Ollama: Generate query embedding
Ollama-->>MCP: Query vector (768-dim)
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
@@ -319,7 +319,7 @@ sequenceDiagram
### Dual-Phase Authorization
**Phase 1: OAuth Scope Check**
- Verify user has `semantic:read` scope
- Verify user has `semantic.read` scope
- Rejects unauthorized users immediately
**Phase 2: Per-Document Verification**
@@ -419,19 +419,19 @@ except Exception as e:
### OAuth Scopes
**`semantic:read`** - Search permission
**`semantic.read`** - Search permission
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
- Does NOT grant access to documents (verified via app APIs)
- Required for any semantic search operation
**`semantic:write`** - Sync control permission
**`semantic.write`** - Sync control permission
- Allows enabling/disabling background sync (`provision_vector_sync`, `deprovision_vector_sync`)
- Controls whether user's documents are indexed
- Currently not implemented in OAuth mode (BasicAuth only)
### Dual-Phase Authorization Pattern
**Phase 1: Scope Check** (semantic:read)
**Phase 1: Scope Check** (semantic.read)
- Verifies user authorized to search
- Prevents unauthorized vector database access
@@ -482,7 +482,7 @@ except Exception as e:
- Credentials stored in `.env` file (secure server access required)
**OAuth:**
- Client authenticates with `semantic:read` scope
- Client authenticates with `semantic.read` scope
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
- Background sync only works for users who provisioned access
- More secure: tokens expire, user controls access
+3 -3
View File
@@ -190,7 +190,7 @@ docker compose logs -f mcp-oauth
1. Get client_id from the JWT client JSON
2. Visit in browser:
```
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
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
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
- ✓ Basic authentication (openid) - required, cannot deselect
- ✓ Profile information (profile)
- ✓ Email address (email)
- ✓ mcp:notes:read (custom scope, shown as-is)
- ✓ mcp:notes:write (custom scope, shown as-is)
- ✓ mcp:notes.read (custom scope, shown as-is)
- ✓ mcp:notes.write (custom scope, shown as-is)
- "Allow" and "Deny" buttons
3. User selects scopes and clicks "Allow"
4. Authorization proceeds with selected scopes
+54 -54
View File
@@ -383,24 +383,24 @@
"phone",
"offline_access",
"microprofile-jwt",
"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"
"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"
]
}
],
@@ -573,7 +573,7 @@
]
},
{
"name": "notes:read",
"name": "notes.read",
"description": "Nextcloud Notes read access",
"protocol": "openid-connect",
"attributes": {
@@ -583,7 +583,7 @@
}
},
{
"name": "notes:write",
"name": "notes.write",
"description": "Nextcloud Notes write access",
"protocol": "openid-connect",
"attributes": {
@@ -593,7 +593,7 @@
}
},
{
"name": "calendar:read",
"name": "calendar.read",
"description": "Nextcloud Calendar read access",
"protocol": "openid-connect",
"attributes": {
@@ -603,7 +603,7 @@
}
},
{
"name": "calendar:write",
"name": "calendar.write",
"description": "Nextcloud Calendar write access",
"protocol": "openid-connect",
"attributes": {
@@ -613,7 +613,7 @@
}
},
{
"name": "contacts:read",
"name": "contacts.read",
"description": "Nextcloud Contacts read access",
"protocol": "openid-connect",
"attributes": {
@@ -623,7 +623,7 @@
}
},
{
"name": "contacts:write",
"name": "contacts.write",
"description": "Nextcloud Contacts write access",
"protocol": "openid-connect",
"attributes": {
@@ -633,7 +633,7 @@
}
},
{
"name": "cookbook:read",
"name": "cookbook.read",
"description": "Nextcloud Cookbook read access",
"protocol": "openid-connect",
"attributes": {
@@ -643,7 +643,7 @@
}
},
{
"name": "cookbook:write",
"name": "cookbook.write",
"description": "Nextcloud Cookbook write access",
"protocol": "openid-connect",
"attributes": {
@@ -653,7 +653,7 @@
}
},
{
"name": "deck:read",
"name": "deck.read",
"description": "Nextcloud Deck read access",
"protocol": "openid-connect",
"attributes": {
@@ -663,7 +663,7 @@
}
},
{
"name": "deck:write",
"name": "deck.write",
"description": "Nextcloud Deck write access",
"protocol": "openid-connect",
"attributes": {
@@ -673,7 +673,7 @@
}
},
{
"name": "tables:read",
"name": "tables.read",
"description": "Nextcloud Tables read access",
"protocol": "openid-connect",
"attributes": {
@@ -683,7 +683,7 @@
}
},
{
"name": "tables:write",
"name": "tables.write",
"description": "Nextcloud Tables write access",
"protocol": "openid-connect",
"attributes": {
@@ -693,7 +693,7 @@
}
},
{
"name": "files:read",
"name": "files.read",
"description": "Nextcloud Files read access",
"protocol": "openid-connect",
"attributes": {
@@ -703,7 +703,7 @@
}
},
{
"name": "files:write",
"name": "files.write",
"description": "Nextcloud Files write access",
"protocol": "openid-connect",
"attributes": {
@@ -713,7 +713,7 @@
}
},
{
"name": "sharing:read",
"name": "sharing.read",
"description": "Nextcloud Sharing read access",
"protocol": "openid-connect",
"attributes": {
@@ -723,7 +723,7 @@
}
},
{
"name": "sharing:write",
"name": "sharing.write",
"description": "Nextcloud Sharing write access",
"protocol": "openid-connect",
"attributes": {
@@ -733,7 +733,7 @@
}
},
{
"name": "todo:read",
"name": "todo.read",
"description": "Nextcloud Tasks/Todo read access",
"protocol": "openid-connect",
"attributes": {
@@ -743,7 +743,7 @@
}
},
{
"name": "todo:write",
"name": "todo.write",
"description": "Nextcloud Tasks/Todo write access",
"protocol": "openid-connect",
"attributes": {
@@ -830,23 +830,23 @@
],
"defaultOptionalClientScopes": [
"offline_access",
"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"
"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"
]
}
@@ -0,0 +1,65 @@
"""Migrate scope separator from colon to dot
Many identity providers reject ':' in OAuth scope names. This migration
updates stored scope strings from the old 'resource:action' format to
'resource.action' (e.g., 'notes:read' -> 'notes.read').
See ADR-023 for rationale.
Revision ID: 004
Revises: 003
Create Date: 2026-04-07 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "004"
down_revision = "003"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Replace colon separator with dot in stored scope strings."""
# Update scopes in app_passwords (JSON array of scope strings)
# Only ':' characters in a JSON array like '["notes:read","calendar:write"]'
# are inside scope name strings, so REPLACE is safe here.
op.execute(
"""
UPDATE app_passwords
SET scopes = REPLACE(scopes, ':', '.')
WHERE scopes IS NOT NULL
"""
)
# Update requested_scopes in login_flow_sessions
op.execute(
"""
UPDATE login_flow_sessions
SET requested_scopes = REPLACE(requested_scopes, ':', '.')
WHERE requested_scopes IS NOT NULL
"""
)
def downgrade() -> None:
"""Revert dot separator back to colon in stored scope strings."""
op.execute(
"""
UPDATE app_passwords
SET scopes = REPLACE(scopes, '.', ':')
WHERE scopes IS NOT NULL
"""
)
op.execute(
"""
UPDATE login_flow_sessions
SET requested_scopes = REPLACE(requested_scopes, '.', ':')
WHERE requested_scopes IS NOT NULL
"""
)
+7 -7
View File
@@ -462,19 +462,19 @@ async def load_oauth_client_credentials(
# These must stay in sync — any scope a tool uses via @require_scopes must be listed here.
dcr_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 "
"news:read news:write collectives:read collectives:write"
"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 "
"news.read news.write collectives.read collectives.write"
)
# Add conditional scopes based on server configuration
dcr_settings = get_settings()
# semantic:read gates MCP-server-level semantic search tools
# semantic.read gates MCP-server-level semantic search tools
if dcr_settings.vector_sync_enabled:
dcr_scopes = f"{dcr_scopes} semantic:read"
logger.info("✓ semantic:read scope enabled for semantic search tools")
dcr_scopes = f"{dcr_scopes} semantic.read"
logger.info("✓ semantic.read scope enabled for semantic search tools")
# offline_access enables refresh tokens for background operations
enable_offline_access = dcr_settings.enable_offline_access
@@ -86,7 +86,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
callback_uri = f"{mcp_server_url}/oauth/callback"
# Request only basic OIDC scopes for browser session
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
# Note: Nextcloud app scopes (notes.read, etc.) are for MCP client access tokens,
# not for the MCP server's own browser authentication
scopes = "openid profile email offline_access"
@@ -74,7 +74,7 @@ def require_scopes(*required_scopes: str):
users who lack the necessary scopes.
Args:
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
*required_scopes: Variable number of scope strings required (e.g., "notes.read", "notes.write")
Returns:
Decorated function that checks scopes before execution
@@ -82,15 +82,15 @@ def require_scopes(*required_scopes: str):
Example:
```python
@mcp.tool()
@require_scopes("notes:read")
@require_scopes("notes.read")
async def nc_notes_get_note(ctx: Context, note_id: int):
# This tool requires the notes:read scope
# This tool requires the notes.read scope
...
@mcp.tool()
@require_scopes("notes:write")
@require_scopes("notes.write")
async def nc_notes_create_note(ctx: Context, ...):
# This tool requires the notes:write scope
# This tool requires the notes.write scope
...
```
@@ -203,12 +203,12 @@ def require_scopes(*required_scopes: str):
if any(
s.startswith(prefix)
for prefix in [
"notes:",
"calendar:",
"contacts:",
"files:",
"tables:",
"deck:",
"notes.",
"calendar.",
"contacts.",
"files.",
"tables.",
"deck.",
]
)
]
@@ -223,12 +223,12 @@ def require_scopes(*required_scopes: str):
s.startswith(prefix)
for s in token_scopes
for prefix in [
"notes:",
"calendar:",
"contacts:",
"files:",
"tables:",
"deck:",
"notes.",
"calendar.",
"contacts.",
"files.",
"tables.",
"deck.",
]
)
@@ -305,7 +305,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
Example:
```python
async def my_tool(ctx: Context):
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
has_scopes, missing = check_scopes(ctx, "notes.read", "notes.write")
if not has_scopes:
# Handle missing scopes
...
@@ -335,11 +335,11 @@ def get_required_scopes(func: Callable) -> list[str]:
Example:
```python
@require_scopes("notes:read", "notes:write")
@require_scopes("notes.read", "notes.write")
async def my_tool():
pass
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
scopes = get_required_scopes(my_tool) # ["notes.read", "notes.write"]
```
"""
return getattr(func, "_required_scopes", [])
@@ -385,14 +385,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
Example:
```python
@require_scopes("notes:write")
@require_scopes("notes.write")
async def create_note():
pass
user_scopes = {"notes:read", "notes:write"}
user_scopes = {"notes.read", "notes.write"}
can_see = has_required_scopes(create_note, user_scopes) # True
limited_user_scopes = {"notes:read"}
limited_user_scopes = {"notes.read"}
can_see = has_required_scopes(create_note, limited_user_scopes) # False
```
"""
@@ -431,17 +431,17 @@ def discover_all_scopes(mcp) -> list[str]:
mcp = FastMCP("My Server")
@mcp.tool()
@require_scopes("notes:read")
@require_scopes("notes.read")
async def get_notes():
pass
@mcp.tool()
@require_scopes("notes:write")
@require_scopes("notes.write")
async def create_note():
pass
scopes = discover_all_scopes(mcp)
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
# Returns: ["notes.read", "notes.write", "openid", "profile", "email"]
```
Note:
+2 -2
View File
@@ -337,7 +337,7 @@ class TokenBrokerService:
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
"scope": "openid profile email offline_access notes.read notes.write calendar.read calendar.write",
"client_id": self.client_id,
"client_secret": self.client_secret,
}
@@ -521,7 +521,7 @@ class TokenBrokerService:
data = {
"grant_type": "refresh_token",
"refresh_token": current_refresh_token,
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
"scope": "openid profile email offline_access notes.read notes.write calendar.read calendar.write",
}
response = await client.post(
+2 -2
View File
@@ -90,7 +90,7 @@ from .app import get_app
@click.option(
"--oauth-scopes",
envvar="NEXTCLOUD_OIDC_SCOPES",
default="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",
default="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",
show_default=True,
help="OAuth scopes to request during client registration. These define the maximum allowed scopes for the client. Note: Actual supported scopes are discovered dynamically from MCP tools at runtime. (can also use NEXTCLOUD_OIDC_SCOPES env var)",
)
@@ -153,7 +153,7 @@ def run(
# OAuth mode with custom scopes and JWT tokens
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
--oauth-scopes="openid notes:read notes:write" --oauth-token-type=jwt
--oauth-scopes="openid notes.read notes.write" --oauth-token-type=jwt
# OAuth with public issuer URL (for Docker/proxy setups)
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
+22 -22
View File
@@ -54,27 +54,27 @@ class UpdateScopesResponse(BaseResponse):
# All supported application-level scopes (frozenset for O(1) membership tests)
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
{
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"tables:read",
"tables:write",
"deck:read",
"deck:write",
"cookbook:read",
"cookbook:write",
"sharing:read",
"sharing:write",
"news:read",
"news:write",
"collectives:read",
"collectives:write",
"notes.read",
"notes.write",
"calendar.read",
"calendar.write",
"todo.read",
"todo.write",
"contacts.read",
"contacts.write",
"files.read",
"files.write",
"tables.read",
"tables.write",
"deck.read",
"deck.write",
"cookbook.read",
"cookbook.write",
"sharing.read",
"sharing.write",
"news.read",
"news.write",
"collectives.read",
"collectives.write",
}
)
@@ -410,7 +410,7 @@ def instrument_tool(func):
Usage:
@mcp.tool()
@require_scopes("notes:write")
@require_scopes("notes.write")
@instrument_tool
async def nc_notes_create_note(...):
...
+1 -1
View File
@@ -56,7 +56,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
Args:
ctx: MCP context
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
scopes: Requested application scopes (e.g. ["notes.read", "calendar.write"]).
If not specified, all available scopes are requested.
Returns:
+16 -16
View File
@@ -55,7 +55,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="List Calendars",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@require_scopes("calendar.read")
@instrument_tool
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user"""
@@ -69,7 +69,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Create Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_create_event(
calendar_name: str,
@@ -149,7 +149,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="List Calendar Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@require_scopes("calendar.read")
@instrument_tool
async def nc_calendar_list_events(
calendar_name: str,
@@ -269,7 +269,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Get Calendar Event",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@require_scopes("calendar.read")
@instrument_tool
async def nc_calendar_get_event(
calendar_name: str,
@@ -285,7 +285,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Update Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_update_event(
calendar_name: str,
@@ -364,7 +364,7 @@ def configure_calendar_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_delete_event(
calendar_name: str,
@@ -379,7 +379,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Create Meeting",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_create_meeting(
title: str,
@@ -449,7 +449,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Get Upcoming Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@require_scopes("calendar.read")
@instrument_tool
async def nc_calendar_get_upcoming_events(
ctx: Context,
@@ -512,7 +512,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Find Availability",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("calendar:read")
@require_scopes("calendar.read")
@instrument_tool
async def nc_calendar_find_availability(
duration_minutes: int,
@@ -596,7 +596,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Bulk Calendar Operations",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_bulk_operations(
operation: str, # "update", "delete", "move"
@@ -849,7 +849,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Manage Calendar",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("calendar:write")
@require_scopes("calendar.write")
@instrument_tool
async def nc_calendar_manage_calendar(
action: str, # "create", "delete", "update", "list"
@@ -922,7 +922,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="List Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("todo:read", "calendar:read")
@require_scopes("todo.read", "calendar.read")
@instrument_tool
async def nc_calendar_list_todos(
calendar_name: str,
@@ -971,7 +971,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Create Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("todo:write", "calendar:read")
@require_scopes("todo.write", "calendar.read")
@instrument_tool
async def nc_calendar_create_todo(
calendar_name: str,
@@ -1018,7 +1018,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Update Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("todo:write", "calendar:read")
@require_scopes("todo.write", "calendar.read")
@instrument_tool
async def nc_calendar_update_todo(
calendar_name: str,
@@ -1084,7 +1084,7 @@ def configure_calendar_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("todo:write", "calendar:read")
@require_scopes("todo.write", "calendar.read")
@instrument_tool
async def nc_calendar_delete_todo(
calendar_name: str,
@@ -1108,7 +1108,7 @@ def configure_calendar_tools(mcp: FastMCP):
title="Search Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("todo:read", "calendar:read")
@require_scopes("todo.read", "calendar.read")
@instrument_tool
async def nc_calendar_search_todos(
ctx: Context,
+20 -20
View File
@@ -48,7 +48,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="List Collectives",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_collectives(
ctx: Context,
@@ -66,7 +66,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="List Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_pages(
ctx: Context, collective_id: int
@@ -90,7 +90,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Get Collective Page",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_page(
ctx: Context, collective_id: int, page_id: int
@@ -138,7 +138,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Search Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_search_pages(
ctx: Context, collective_id: int, query: str
@@ -166,7 +166,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="List Collective Tags",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_tags(
ctx: Context, collective_id: int
@@ -188,7 +188,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="List Trashed Collective Pages",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_trashed_pages(
ctx: Context, collective_id: int
@@ -212,7 +212,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="List Trashed Collectives",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("collectives:read")
@require_scopes("collectives.read")
@instrument_tool
async def collectives_get_trashed_collectives(
ctx: Context,
@@ -238,7 +238,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Create Collective",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_create_collective(
ctx: Context, name: str, emoji: str | None = None
@@ -263,7 +263,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Set Collective Emoji",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_set_collective_emoji(
ctx: Context, collective_id: int, emoji: str | None = None
@@ -295,7 +295,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Trash Collective",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_trash_collective(
ctx: Context, collective_id: int
@@ -324,7 +324,7 @@ def configure_collectives_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=False, openWorldHint=True
),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_delete_collective(
ctx: Context, collective_id: int
@@ -353,7 +353,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Restore Collective",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_restore_collective(
ctx: Context, collective_id: int
@@ -379,7 +379,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Create Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_create_page(
ctx: Context, collective_id: int, parent_id: int, title: str
@@ -412,7 +412,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Move Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_move_page(
ctx: Context,
@@ -453,7 +453,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Trash Collective Page",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_trash_page(
ctx: Context, collective_id: int, page_id: int
@@ -484,7 +484,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Restore Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_restore_page(
ctx: Context, collective_id: int, page_id: int
@@ -512,7 +512,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Set Collective Page Emoji",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_set_page_emoji(
ctx: Context,
@@ -544,7 +544,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Create Collective Tag",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_create_tag(
ctx: Context, collective_id: int, name: str, color: str
@@ -568,7 +568,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Assign Tag to Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_assign_tag(
ctx: Context, collective_id: int, page_id: int, tag_id: int
@@ -596,7 +596,7 @@ def configure_collectives_tools(mcp: FastMCP):
title="Remove Tag from Collective Page",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("collectives:write")
@require_scopes("collectives.write")
@instrument_tool
async def collectives_remove_tag(
ctx: Context, collective_id: int, page_id: int, tag_id: int
+7 -7
View File
@@ -99,7 +99,7 @@ def configure_contacts_tools(mcp: FastMCP):
title="List Address Books",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("contacts:read")
@require_scopes("contacts.read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
"""List all addressbooks for the user."""
@@ -123,7 +123,7 @@ def configure_contacts_tools(mcp: FastMCP):
title="List Contacts",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("contacts:read")
@require_scopes("contacts.read")
@instrument_tool
async def nc_contacts_list_contacts(
ctx: Context, *, addressbook: str
@@ -146,7 +146,7 @@ def configure_contacts_tools(mcp: FastMCP):
title="Create Address Book",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@require_scopes("contacts.write")
@instrument_tool
async def nc_contacts_create_addressbook(
ctx: Context, *, name: str, display_name: str
@@ -168,7 +168,7 @@ def configure_contacts_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("contacts:write")
@require_scopes("contacts.write")
@instrument_tool
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
"""Delete an addressbook."""
@@ -179,7 +179,7 @@ def configure_contacts_tools(mcp: FastMCP):
title="Create Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@require_scopes("contacts.write")
@instrument_tool
async def nc_contacts_create_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
@@ -204,7 +204,7 @@ def configure_contacts_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("contacts:write")
@require_scopes("contacts.write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact.
@@ -222,7 +222,7 @@ def configure_contacts_tools(mcp: FastMCP):
title="Update Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("contacts:write")
@require_scopes("contacts.write")
@instrument_tool
async def nc_contacts_update_contact(
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
+13 -13
View File
@@ -75,7 +75,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Import Recipe from URL",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
"""Import a recipe from a URL using schema.org metadata.
@@ -136,7 +136,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="List Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database"""
@@ -165,7 +165,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Get Recipe",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID"""
@@ -194,7 +194,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Create Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_create_recipe(
name: str,
@@ -277,7 +277,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Update Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_update_recipe(
recipe_id: int,
@@ -372,7 +372,7 @@ def configure_cookbook_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_delete_recipe(
recipe_id: int, ctx: Context
@@ -411,7 +411,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Search Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_search_recipes(
query: str, ctx: Context
@@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="List Recipe Categories",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
"""Get all known categories.
@@ -482,7 +482,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Get Recipes in Category",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_get_recipes_in_category(
category: str, ctx: Context
@@ -522,7 +522,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="List Recipe Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags"""
@@ -551,7 +551,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Get Recipes with Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("cookbook:read")
@require_scopes("cookbook.read")
@instrument_tool
async def nc_cookbook_get_recipes_with_keywords(
keywords: list[str], ctx: Context
@@ -589,7 +589,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Set Cookbook Configuration",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_set_config(
folder: str | None = None,
@@ -636,7 +636,7 @@ def configure_cookbook_tools(mcp: FastMCP):
title="Reindex Recipes",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("cookbook:write")
@require_scopes("cookbook.write")
@instrument_tool
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
"""Trigger a rescan of all recipes into the caching database.
+25 -25
View File
@@ -126,7 +126,7 @@ def configure_deck_tools(mcp: FastMCP):
title="List Deck Boards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
"""Get all Nextcloud Deck boards"""
@@ -138,7 +138,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Get Deck Board",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board"""
@@ -150,7 +150,7 @@ def configure_deck_tools(mcp: FastMCP):
title="List Deck Stacks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
"""Get all stacks in a Nextcloud Deck board"""
@@ -162,7 +162,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Get Deck Stack",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack"""
@@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP):
title="List Deck Cards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_cards(
ctx: Context, board_id: int, stack_id: int
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Get Deck Card",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
@@ -203,7 +203,7 @@ def configure_deck_tools(mcp: FastMCP):
title="List Deck Labels",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
"""Get all labels in a Nextcloud Deck board"""
@@ -216,7 +216,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Get Deck Label",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("deck:read")
@require_scopes("deck.read")
@instrument_tool
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label"""
@@ -230,7 +230,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Create Deck Board",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_create_board(
ctx: Context, title: str, color: str
@@ -251,7 +251,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Create Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_create_stack(
ctx: Context, board_id: int, title: str, order: int
@@ -271,7 +271,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Update Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_update_stack(
ctx: Context,
@@ -303,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_delete_stack(
ctx: Context, board_id: int, stack_id: int
@@ -328,7 +328,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Create Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_create_card(
ctx: Context,
@@ -366,7 +366,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Update Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_update_card(
ctx: Context,
@@ -425,7 +425,7 @@ def configure_deck_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_delete_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
@@ -451,7 +451,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Archive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_archive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
@@ -477,7 +477,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Unarchive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_unarchive_card(
ctx: Context, board_id: int, stack_id: int, card_id: int
@@ -503,7 +503,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Reorder/Move Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_reorder_card(
ctx: Context,
@@ -539,7 +539,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Create Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_create_label(
ctx: Context, board_id: int, title: str, color: str
@@ -559,7 +559,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Update Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_update_label(
ctx: Context,
@@ -591,7 +591,7 @@ def configure_deck_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_delete_label(
ctx: Context, board_id: int, label_id: int
@@ -616,7 +616,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Assign Label to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_assign_label_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
@@ -643,7 +643,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Remove Label from Deck Card",
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_remove_label_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
@@ -671,7 +671,7 @@ def configure_deck_tools(mcp: FastMCP):
title="Assign User to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_assign_user_to_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
@@ -700,7 +700,7 @@ def configure_deck_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("deck:write")
@require_scopes("deck.write")
@instrument_tool
async def deck_unassign_user_from_card(
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
+16 -16
View File
@@ -34,10 +34,10 @@ def configure_news_tools(mcp: FastMCP):
title="List News Folders",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
"""List all News folders (requires news:read scope)."""
"""List all News folders (requires news.read scope)."""
client = await get_client(ctx)
try:
folders_data = await client.news.get_folders()
@@ -59,10 +59,10 @@ def configure_news_tools(mcp: FastMCP):
title="List News Feeds",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
"""List all News feeds with metadata (requires news:read scope).
"""List all News feeds with metadata (requires news.read scope).
Returns feeds with unread counts, error status, and overall starred count.
"""
@@ -92,7 +92,7 @@ def configure_news_tools(mcp: FastMCP):
title="List News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_list_items(
ctx: Context,
@@ -103,7 +103,7 @@ def configure_news_tools(mcp: FastMCP):
limit: int = 50,
offset: int = 0,
) -> ListItemsResponse:
"""List News items (articles) with optional filtering (requires news:read scope).
"""List News items (articles) with optional filtering (requires news.read scope).
Args:
feed_id: Filter by specific feed ID
@@ -166,10 +166,10 @@ def configure_news_tools(mcp: FastMCP):
title="Get News Item",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
"""Get a specific News item by ID with full content (requires news:read scope).
"""Get a specific News item by ID with full content (requires news.read scope).
Args:
item_id: Item ID
@@ -204,12 +204,12 @@ def configure_news_tools(mcp: FastMCP):
title="Get Starred News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_get_starred_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get starred (favorited) News items (requires news:read scope).
"""Get starred (favorited) News items (requires news.read scope).
Convenience method for retrieving user's starred articles.
@@ -257,12 +257,12 @@ def configure_news_tools(mcp: FastMCP):
title="Get Unread News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_get_unread_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get unread News items (requires news:read scope).
"""Get unread News items (requires news.read scope).
Convenience method for retrieving unread articles across all feeds.
@@ -310,10 +310,10 @@ def configure_news_tools(mcp: FastMCP):
title="Get News Feed Health",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
"""Get health status for a specific feed (requires news:read scope).
"""Get health status for a specific feed (requires news.read scope).
Returns error count and last error message if the feed has update issues.
@@ -357,10 +357,10 @@ def configure_news_tools(mcp: FastMCP):
title="Get News App Status",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@require_scopes("news.read")
@instrument_tool
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
"""Get News app status and version (requires news:read scope).
"""Get News app status and version (requires news.read scope).
Returns version information and any configuration warnings.
"""
+11 -11
View File
@@ -92,12 +92,12 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:write")
@require_scopes("notes.write")
@instrument_tool
async def nc_notes_create_note(
title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse:
"""Create a new note (requires notes:write scope)"""
"""Create a new note (requires notes.write scope)"""
client = await get_client(ctx)
try:
note_data = await client.notes.create_note(
@@ -145,7 +145,7 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:write")
@require_scopes("notes.write")
@instrument_tool
async def nc_notes_update_note(
note_id: int,
@@ -155,7 +155,7 @@ def configure_notes_tools(mcp: FastMCP):
category: str | None,
ctx: Context,
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category (requires notes: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.
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
@@ -217,7 +217,7 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:write")
@require_scopes("notes.write")
@instrument_tool
async def nc_notes_append_content(
note_id: int, content: str, ctx: Context
@@ -274,10 +274,10 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:read")
@require_scopes("notes.read")
@instrument_tool
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
"""Search notes by title or content, returning only id, title, and category (requires notes.read scope)."""
client = await get_client(ctx)
try:
search_results_raw = await client.notes_search_notes(query=query)
@@ -327,10 +327,10 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:read")
@require_scopes("notes.read")
@instrument_tool
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID (requires notes:read scope)"""
"""Get a specific note by its ID (requires notes.read scope)"""
client = await get_client(ctx)
try:
note_data = await client.notes.get_note(note_id)
@@ -363,7 +363,7 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:read")
@require_scopes("notes.read")
@instrument_tool
async def nc_notes_get_attachment(
note_id: int, attachment_filename: str, ctx: Context
@@ -417,7 +417,7 @@ def configure_notes_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("notes:write")
@require_scopes("notes.write")
@instrument_tool
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently"""
+8 -8
View File
@@ -473,14 +473,14 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
"profile",
"email",
"offline_access", # Critical for background operations
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"notes.read",
"notes.write",
"calendar.read",
"calendar.write",
"contacts.read",
"contacts.write",
"files.read",
"files.write",
]
# Generate authorization URL
+3 -3
View File
@@ -48,7 +48,7 @@ def configure_semantic_tools(mcp: FastMCP):
openWorldHint=True, # Queries external Nextcloud service
),
)
@require_scopes("semantic:read")
@require_scopes("semantic.read")
@instrument_tool
async def nc_semantic_search(
query: str,
@@ -303,7 +303,7 @@ def configure_semantic_tools(mcp: FastMCP):
openWorldHint=False, # Searches only indexed Nextcloud data
),
)
@require_scopes("semantic:read")
@require_scopes("semantic.read")
@instrument_tool
async def nc_semantic_search_answer(
query: str,
@@ -645,7 +645,7 @@ def configure_semantic_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("semantic:read")
@require_scopes("semantic.read")
@instrument_tool
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
"""Get the current vector sync status.
+5 -5
View File
@@ -21,7 +21,7 @@ def configure_sharing_tools(mcp: FastMCP):
title="Create Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("sharing:write")
@require_scopes("sharing.write")
@instrument_tool
async def nc_share_create(
path: str,
@@ -66,7 +66,7 @@ def configure_sharing_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("sharing:write")
@require_scopes("sharing.write")
@instrument_tool
async def nc_share_delete(share_id: int, ctx: Context) -> str:
"""Delete a share by its ID.
@@ -89,7 +89,7 @@ def configure_sharing_tools(mcp: FastMCP):
title="Get Share Details",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("sharing:write")
@require_scopes("sharing.write")
@instrument_tool
async def nc_share_get(share_id: int, ctx: Context) -> str:
"""Get information about a specific share.
@@ -111,7 +111,7 @@ def configure_sharing_tools(mcp: FastMCP):
title="List Shares",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("sharing:write")
@require_scopes("sharing.write")
@instrument_tool
async def nc_share_list(
ctx: Context, path: str | None = None, shared_with_me: bool = False
@@ -136,7 +136,7 @@ def configure_sharing_tools(mcp: FastMCP):
title="Update Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("sharing:write")
@require_scopes("sharing.write")
@instrument_tool
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
"""Update the permissions of an existing share.
+6 -6
View File
@@ -17,7 +17,7 @@ def configure_tables_tools(mcp: FastMCP):
title="List Tables",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@require_scopes("tables.read")
@instrument_tool
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
"""List all tables available to the user"""
@@ -30,7 +30,7 @@ def configure_tables_tools(mcp: FastMCP):
title="Get Table Schema",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@require_scopes("tables.read")
@instrument_tool
async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views"""
@@ -41,7 +41,7 @@ def configure_tables_tools(mcp: FastMCP):
title="Read Table Rows",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("tables:read")
@require_scopes("tables.read")
@instrument_tool
async def nc_tables_read_table(
table_id: int,
@@ -57,7 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
title="Insert Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("tables:write")
@require_scopes("tables.write")
@instrument_tool
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
"""Insert a new row into a table.
@@ -71,7 +71,7 @@ def configure_tables_tools(mcp: FastMCP):
title="Update Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@require_scopes("tables:write")
@require_scopes("tables.write")
@instrument_tool
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
"""Update an existing row in a table.
@@ -87,7 +87,7 @@ def configure_tables_tools(mcp: FastMCP):
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@require_scopes("tables:write")
@require_scopes("tables.write")
@instrument_tool
async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table"""
+11 -11
View File
@@ -25,7 +25,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_list_directory(
ctx: Context, path: str = ""
@@ -65,7 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_read_file(path: str, ctx: Context):
"""Read the content of a file from NextCloud.
@@ -137,7 +137,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:write")
@require_scopes("files.write")
@instrument_tool
async def nc_webdav_write_file(
path: str, content: str, ctx: Context, content_type: str | None = None
@@ -170,7 +170,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:write")
@require_scopes("files.write")
@instrument_tool
async def nc_webdav_create_directory(path: str, ctx: Context):
"""Create a directory in NextCloud.
@@ -192,7 +192,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:write")
@require_scopes("files.write")
@instrument_tool
async def nc_webdav_delete_resource(path: str, ctx: Context):
"""Delete a file or directory in NextCloud.
@@ -213,7 +213,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:write")
@require_scopes("files.write")
@instrument_tool
async def nc_webdav_move_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
@@ -240,7 +240,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:write")
@require_scopes("files.write")
@instrument_tool
async def nc_webdav_copy_resource(
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
@@ -267,7 +267,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_search_files(
ctx: Context,
@@ -390,7 +390,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_find_by_name(
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
@@ -424,7 +424,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_find_by_type(
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
@@ -458,7 +458,7 @@ def configure_webdav_tools(mcp: FastMCP):
openWorldHint=True,
),
)
@require_scopes("files:read")
@require_scopes("files.read")
@instrument_tool
async def nc_webdav_list_favorites(
ctx: Context, scope: str = "", limit: int | None = None
+4 -4
View File
@@ -46,10 +46,10 @@ logger = logging.getLogger(__name__)
# Scopes required for vector sync operations
VECTOR_SYNC_SCOPES = [
"notes:read",
"files:read",
"deck:read",
# "news:read", # News app may not be installed
"notes.read",
"files.read",
"deck.read",
# "news.read", # News app may not be installed
]
+33 -33
View File
@@ -29,43 +29,43 @@ 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"
"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"
"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"
"notes.write "
"calendar.write "
"todo.write "
"contacts.write "
"cookbook.write "
"deck.write "
"tables.write "
"files.write "
"sharing.write"
)
@@ -545,7 +545,7 @@ async def nc_mcp_oauth_client_no_custom_scopes(
Connects to the OAuth-enabled MCP server on port 8001.
This client has only OIDC default scopes (openid, profile, email) without
application-specific scopes (notes:read, notes:write, etc.).
application-specific scopes (notes.read, notes.write, etc.).
Expected behavior: Should see 0 tools (all tools require custom scopes).
@@ -1671,7 +1671,7 @@ async def no_custom_scopes_oauth_client_credentials(
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
Tests the security behavior when a user grants only the default OIDC scopes
(openid, profile, email) but declines custom application scopes (notes:read, notes:write, etc.).
(openid, profile, email) but declines custom application scopes (notes.read, notes.write, etc.).
The client is automatically deleted from Nextcloud after the test session completes.
@@ -1808,7 +1808,7 @@ async def playwright_oauth_token(
f"client_id={client_id}&"
f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
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"
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"
)
# Add resource parameter (RFC 8707) if available
@@ -2060,7 +2060,7 @@ async def _get_oauth_token_with_scopes(
browser: Playwright browser instance
shared_oauth_client_credentials: Tuple of OAuth client credentials
oauth_callback_server: OAuth callback server fixture
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
scopes: Space-separated list of scopes (e.g., "openid profile email notes.read")
resource: Optional resource parameter (RFC 8707) for token audience
mcp_server_base_url: Base URL of the MCP server for resource metadata discovery
@@ -2521,7 +2521,7 @@ async def _get_oauth_token_for_user(
f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"resource={quote(mcp_server_resource, safe='')}&" # Resource URI from PRM
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"
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}...")
@@ -3038,7 +3038,7 @@ async def configure_astrolabe_for_mcp_server(nc_client):
"--resource_url",
mcp_server_public_url,
"--allowed_scopes",
"openid profile email offline_access 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",
"openid profile email offline_access 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",
],
check=True,
capture_output=True,
@@ -37,7 +37,7 @@ async def get_oauth_token_with_client(
authorization_endpoint: str,
callback_url: str,
auth_states: dict,
scopes: str = "openid profile email notes:read notes:write",
scopes: str = "openid profile email notes.read notes.write",
) -> str:
"""
Helper to obtain OAuth access token using existing client credentials.
@@ -187,7 +187,7 @@ async def test_dcr_register_and_delete_lifecycle(
"token_endpoint_auth_method": "client_secret_post",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile email notes:read",
"scope": "openid profile email notes.read",
"token_type": "Bearer",
}
@@ -209,7 +209,7 @@ async def test_dcr_register_and_delete_lifecycle(
registration_endpoint=registration_endpoint,
client_name="DCR Lifecycle Test Client 2",
redirect_uris=[callback_url],
scopes="openid profile email notes:read",
scopes="openid profile email notes.read",
token_type="Bearer",
)
@@ -235,7 +235,7 @@ async def test_dcr_register_and_delete_lifecycle(
authorization_endpoint=authorization_endpoint,
callback_url=callback_url,
auth_states=auth_states,
scopes="openid profile email notes:read",
scopes="openid profile email notes.read",
)
assert access_token, "Failed to obtain access token"
@@ -93,7 +93,7 @@ async def get_oauth_token_with_client(
authorization_endpoint: str,
callback_url: str,
auth_states: dict,
scopes: str = "openid profile email notes:read notes:write",
scopes: str = "openid profile email notes.read notes.write",
) -> str:
"""
Helper to obtain OAuth access token using existing client credentials.
@@ -241,7 +241,7 @@ async def test_dcr_respects_jwt_token_type(
registration_endpoint=registration_endpoint,
client_name="DCR Test - JWT Token Type",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write",
scopes="openid profile email notes.read notes.write",
token_type="jwt",
)
@@ -276,8 +276,8 @@ async def test_dcr_respects_jwt_token_type(
# 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"
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! "
@@ -325,7 +325,7 @@ async def test_dcr_respects_bearer_token_type(
registration_endpoint=registration_endpoint,
client_name="DCR Test - Opaque Token Type",
redirect_uris=[callback_url],
scopes="openid profile email notes:read notes:write",
scopes="openid profile email notes.read notes.write",
token_type="opaque",
)
@@ -31,8 +31,8 @@ async def test_prm_endpoint():
prm_data = response.json()
assert prm_data["resource"] == "http://localhost:8004/mcp"
assert "notes:read" in prm_data["scopes_supported"]
assert "notes:write" in prm_data["scopes_supported"]
assert "notes.read" in prm_data["scopes_supported"]
assert "notes.write" in prm_data["scopes_supported"]
assert "http://localhost:8004" in prm_data["authorization_servers"]
assert "header" in prm_data["bearer_methods_supported"]
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
@@ -67,7 +67,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
logger = logging.getLogger(__name__)
# Connect with token that has only "notes:read" scope
# Connect with token that has only "notes.read" scope
result = await nc_mcp_login_flow_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -76,13 +76,13 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
logger.info(f"Read-only token sees {len(tool_names)} tools")
# Verify read tools are present (only for apps with :read scopes)
# Read-only token has: notes:read, calendar:read, contacts:read,
# cookbook:read, deck:read, tables:read, files:read, sharing:read
# Read-only token has: notes.read, calendar.read, contacts.read,
# cookbook.read, deck.read, tables.read, files.read, sharing.read
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
"nc_notes_get_note", # notes.read
"nc_notes_search_notes", # notes.read
"nc_calendar_list_calendars", # calendar.read
"nc_calendar_get_event", # calendar.read
]
for tool in expected_read_tools:
@@ -90,12 +90,12 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
# Verify write tools are NOT present (filtered out)
write_tools_should_be_filtered = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
"nc_notes_create_note", # notes.write
"nc_notes_update_note", # notes.write
"nc_notes_delete_note", # notes.write
"nc_calendar_create_event", # calendar.write
"nc_calendar_update_event", # calendar.write
"nc_calendar_delete_event", # calendar.write
]
for tool in write_tools_should_be_filtered:
@@ -116,7 +116,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
logger = logging.getLogger(__name__)
# Connect with token that has only "notes:write" scope
# Connect with token that has only "notes.write" scope
result = await nc_mcp_login_flow_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -125,15 +125,15 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
logger.info(f"Write-only token sees {len(tool_names)} tools")
# Verify write tools are present
# Write-only token has: notes:write, calendar:write, contacts:write,
# cookbook:write, deck:write, tables:write, files:write, sharing:write
# Write-only token has: notes.write, calendar.write, contacts.write,
# cookbook.write, deck.write, tables.write, files.write, sharing.write
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
"nc_notes_create_note", # notes.write
"nc_notes_update_note", # notes.write
"nc_notes_delete_note", # notes.write
"nc_calendar_create_event", # calendar.write
"nc_calendar_update_event", # calendar.write
"nc_calendar_delete_event", # calendar.write
]
for tool in expected_write_tools:
@@ -141,10 +141,10 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
# Verify read-only tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
"nc_notes_get_note", # notes.read
"nc_notes_search_notes", # notes.read
"nc_calendar_list_calendars", # calendar.read
"nc_calendar_get_event", # calendar.read
]
for tool in read_tools_should_be_filtered:
@@ -165,7 +165,7 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
logger = logging.getLogger(__name__)
# Connect with token that has both "notes:read" and "notes:write" scopes
# Connect with token that has both "notes.read" and "notes.write" scopes
result = await nc_mcp_login_flow_client_full_access.list_tools()
assert result is not None
assert len(result.tools) > 0
@@ -177,14 +177,14 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
# Verify both read and write tools are present
# Full access has all *read and *write scopes
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_notes_get_note", # notes.read
"nc_notes_search_notes", # notes.read
"nc_calendar_list_calendars", # calendar.read
]
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_notes_create_note", # notes.write
"nc_calendar_create_event", # calendar.write
]
for tool in expected_read_tools:
@@ -217,17 +217,17 @@ async def test_scope_helper_functions():
pass
# Add scope metadata
mock_read_tool._required_scopes = ["notes:read"] # type: ignore
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
mock_read_tool._required_scopes = ["notes.read"] # type: ignore
mock_write_tool._required_scopes = ["notes.write"] # type: ignore
# Test get_required_scopes
assert get_required_scopes(mock_read_tool) == ["notes:read"]
assert get_required_scopes(mock_write_tool) == ["notes:write"]
assert get_required_scopes(mock_read_tool) == ["notes.read"]
assert get_required_scopes(mock_write_tool) == ["notes.write"]
assert get_required_scopes(mock_no_scope_tool) == []
# Test has_required_scopes
read_only_scopes = {"notes:read"}
full_scopes = {"notes:read", "notes:write"}
read_only_scopes = {"notes.read"}
full_scopes = {"notes.read", "notes.write"}
no_scopes = set()
# User with only read scope
@@ -251,13 +251,13 @@ async def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator properly stores metadata."""
from nextcloud_mcp_server.auth import require_scopes
@require_scopes("notes:read", "notes:write")
@require_scopes("notes.read", "notes.write")
async def test_function():
pass
# Check that metadata was stored
assert hasattr(test_function, "_required_scopes")
assert test_function._required_scopes == ["notes:read", "notes:write"]
assert test_function._required_scopes == ["notes.read", "notes.write"]
@pytest.mark.integration
@@ -308,28 +308,28 @@ async def test_scope_classification():
from scripts.add_scope_decorators_simple import classify_function
# Test read operations
assert classify_function("nc_notes_get_note") == "notes:read"
assert classify_function("nc_notes_search_notes") == "notes:read"
assert classify_function("nc_calendar_list_events") == "calendar:read"
assert classify_function("nc_webdav_read_file") == "files:read"
assert classify_function("nc_calendar_find_availability") == "calendar:read"
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
assert classify_function("nc_notes_get_note") == "notes.read"
assert classify_function("nc_notes_search_notes") == "notes.read"
assert classify_function("nc_calendar_list_events") == "calendar.read"
assert classify_function("nc_webdav_read_file") == "files.read"
assert classify_function("nc_calendar_find_availability") == "calendar.read"
assert classify_function("nc_calendar_get_upcoming_events") == "notes.read"
# Test write operations
assert classify_function("nc_notes_create_note") == "notes:write"
assert classify_function("nc_notes_update_note") == "notes:write"
assert classify_function("nc_notes_delete_note") == "notes:write"
assert classify_function("nc_notes_append_content") == "notes:write"
assert classify_function("nc_calendar_create_event") == "calendar:write"
assert classify_function("nc_calendar_update_event") == "notes:write"
assert classify_function("nc_calendar_manage_calendar") == "notes:write"
assert classify_function("nc_webdav_write_file") == "files:write"
assert classify_function("nc_webdav_move_resource") == "notes:write"
assert classify_function("nc_contacts_create_contact") == "notes:write"
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
assert classify_function("nc_tables_insert_row") == "notes:write"
assert classify_function("deck_archive_card") == "notes:write"
assert classify_function("deck_assign_label_to_card") == "notes:write"
assert classify_function("nc_notes_create_note") == "notes.write"
assert classify_function("nc_notes_update_note") == "notes.write"
assert classify_function("nc_notes_delete_note") == "notes.write"
assert classify_function("nc_notes_append_content") == "notes.write"
assert classify_function("nc_calendar_create_event") == "calendar.write"
assert classify_function("nc_calendar_update_event") == "notes.write"
assert classify_function("nc_calendar_manage_calendar") == "notes.write"
assert classify_function("nc_webdav_write_file") == "files.write"
assert classify_function("nc_webdav_move_resource") == "notes.write"
assert classify_function("nc_contacts_create_contact") == "notes.write"
assert classify_function("nc_cookbook_import_recipe") == "notes.write"
assert classify_function("nc_tables_insert_row") == "notes.write"
assert classify_function("deck_archive_card") == "notes.write"
assert classify_function("deck_assign_label_to_card") == "notes.write"
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
+9 -9
View File
@@ -251,15 +251,15 @@ def test_default_values(runner, clean_env, monkeypatch):
# Verify default values
assert captured_env["NEXTCLOUD_OIDC_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"
"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"
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
+8 -8
View File
@@ -100,7 +100,7 @@ class TestGetUserAccess:
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read", "calendar:write"],
scopes=["notes.read", "calendar.write"],
username="alice_nc",
)
@@ -115,7 +115,7 @@ class TestGetUserAccess:
data = resp.json()
assert data["success"] is True
assert data["provisioned"] is True
assert set(data["scopes"]) == {"notes:read", "calendar:write"}
assert set(data["scopes"]) == {"notes.read", "calendar.write"}
assert data["username"] == "alice_nc"
async def test_missing_auth_header(self, temp_storage):
@@ -146,7 +146,7 @@ class TestUpdateUserScopes:
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read"],
scopes=["notes.read"],
username="alice_nc",
)
@@ -156,19 +156,19 @@ class TestUpdateUserScopes:
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read", "notes:write", "calendar:read"]},
json={"scopes": ["notes.read", "notes.write", "calendar.read"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert set(data["scopes"]) == {"notes:read", "notes:write", "calendar:read"}
assert set(data["scopes"]) == {"notes.read", "notes.write", "calendar.read"}
async def test_invalid_scopes(self, temp_storage):
"""Returns 400 for invalid scope names."""
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read"],
scopes=["notes.read"],
)
app = create_test_app(temp_storage)
@@ -177,7 +177,7 @@ class TestUpdateUserScopes:
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read", "invalid:scope"]},
json={"scopes": ["notes.read", "invalid:scope"]},
)
assert resp.status_code == 400
data = resp.json()
@@ -192,7 +192,7 @@ class TestUpdateUserScopes:
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read"]},
json={"scopes": ["notes.read"]},
)
assert resp.status_code == 404
data = resp.json()
+12 -12
View File
@@ -38,14 +38,14 @@ async def test_store_app_password_with_scopes(temp_storage):
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
scopes=["notes:read", "notes:write"],
scopes=["notes.read", "notes.write"],
username="alice_nc",
)
data = await temp_storage.get_app_password_with_scopes("alice")
assert data is not None
assert data["app_password"] == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
assert data["scopes"] == ["notes:read", "notes:write"]
assert data["scopes"] == ["notes.read", "notes.write"]
assert data["username"] == "alice_nc"
assert data["created_at"] is not None
assert data["updated_at"] is not None
@@ -70,18 +70,18 @@ async def test_store_app_password_with_scopes_replaces(temp_storage):
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
scopes=["notes:read"],
scopes=["notes.read"],
)
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
scopes=["notes:read", "calendar:read"],
scopes=["notes.read", "calendar.read"],
username="alice_nc",
)
data = await temp_storage.get_app_password_with_scopes("alice")
assert data["app_password"] == "xxxxx-yyyyy-zzzzz-aaaaa-bbbbb"
assert data["scopes"] == ["notes:read", "calendar:read"]
assert data["scopes"] == ["notes.read", "calendar.read"]
async def test_get_app_password_with_scopes_nonexistent(temp_storage):
@@ -99,14 +99,14 @@ async def test_store_and_get_login_flow_session(temp_storage):
user_id="alice",
poll_token="secret-poll-token",
poll_endpoint="https://cloud.example.com/login/v2/poll",
requested_scopes=["notes:read", "notes:write"],
requested_scopes=["notes.read", "notes.write"],
)
session = await temp_storage.get_login_flow_session("alice")
assert session is not None
assert session["poll_token"] == "secret-poll-token"
assert session["poll_endpoint"] == "https://cloud.example.com/login/v2/poll"
assert session["requested_scopes"] == ["notes:read", "notes:write"]
assert session["requested_scopes"] == ["notes.read", "notes.write"]
assert session["created_at"] is not None
assert session["expires_at"] is not None
@@ -187,11 +187,11 @@ async def test_delete_expired_login_flow_sessions(temp_storage):
def test_all_supported_scopes():
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
assert "notes:read" in ALL_SUPPORTED_SCOPES
assert "notes:write" in ALL_SUPPORTED_SCOPES
assert "calendar:read" in ALL_SUPPORTED_SCOPES
assert "files:read" in ALL_SUPPORTED_SCOPES
assert "deck:read" in ALL_SUPPORTED_SCOPES
assert "notes.read" in ALL_SUPPORTED_SCOPES
assert "notes.write" in ALL_SUPPORTED_SCOPES
assert "calendar.read" in ALL_SUPPORTED_SCOPES
assert "files.read" in ALL_SUPPORTED_SCOPES
assert "deck.read" in ALL_SUPPORTED_SCOPES
# Scopes should be in pairs (read/write)
read_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":read")]
write_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":write")]
@@ -29,7 +29,7 @@ async def test_get_stored_scopes_with_scopes():
mock_storage = AsyncMock()
mock_storage.get_app_password_with_scopes.return_value = {
"app_password": "xxxxx",
"scopes": ["notes:read", "calendar:read"],
"scopes": ["notes.read", "calendar.read"],
"username": "alice",
"created_at": 1000,
"updated_at": 1000,
@@ -41,7 +41,7 @@ async def test_get_stored_scopes_with_scopes():
):
result = await _get_stored_scopes("alice")
assert result == ["notes:read", "calendar:read"]
assert result == ["notes.read", "calendar.read"]
async def test_get_stored_scopes_null_scopes():
+8 -8
View File
@@ -12,24 +12,24 @@ from nextcloud_mcp_server.auth.scope_authorization import (
def test_scope_decorator_stores_metadata():
"""Test that @require_scopes decorator stores scope requirements as function metadata."""
@require_scopes("notes:read", "notes:write")
@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"]
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")
@require_scopes("calendar.read")
async def example_function():
pass
assert example_function._required_scopes == ["calendar:read"]
assert example_function._required_scopes == ["calendar.read"]
@pytest.mark.unit
@@ -46,18 +46,18 @@ def test_scope_decorator_with_no_scopes():
@pytest.mark.unit
def test_insufficient_scope_error():
"""Test InsufficientScopeError exception structure."""
missing = ["notes:write", "calendar:write"]
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)
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"]
missing = ["files.write"]
custom_msg = "You need more permissions"
error = InsufficientScopeError(missing, custom_msg)
+3 -3
View File
@@ -379,7 +379,7 @@ class TestRefreshTokenRotation:
expires_in,
) = await broker._refresh_access_token_with_scopes(
refresh_token="old_refresh_token_123",
required_scopes=["notes:read"],
required_scopes=["notes.read"],
user_id="admin",
)
@@ -424,7 +424,7 @@ class TestRefreshTokenRotation:
):
await broker._refresh_access_token_with_scopes(
refresh_token="same_refresh_token",
required_scopes=["notes:read"],
required_scopes=["notes.read"],
user_id="admin",
)
@@ -460,7 +460,7 @@ class TestRefreshTokenRotation:
):
await broker._refresh_access_token_with_scopes(
refresh_token="old_token",
required_scopes=["notes:read"],
required_scopes=["notes.read"],
user_id=None, # No user_id
)