From 29fd0486c944af13fc580bb2fdd184d0ee40b2f6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 7 Apr 2026 10:07:02 +0200 Subject: [PATCH] refactor: change OAuth scope separator from colon to dot for IDP compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle colons in OAuth scope names. This migrates all custom scopes from `resource:action` to `resource.action` format (e.g., `notes:read` → `notes.read`), which is universally accepted and aligns with industry conventions (Microsoft, Google). Includes Alembic migration 004 for stored scope strings and ADR-024 documenting the rationale and RFC references. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- .../26-configure-astrolabe-oauth.sh | 2 +- charts/nextcloud-mcp-server/values.yaml | 4 +- docker-compose.yml | 2 +- docs/ADR-024-scope-separator-migration.md | 139 ++++++++++++++++++ docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md | 6 +- docs/jwt-oauth-reference.md | 86 +++++------ docs/oauth-architecture.md | 86 +++++------ docs/oauth-troubleshooting.md | 20 +-- docs/semantic-search-architecture.md | 12 +- docs/testing-oidc-consent.md | 6 +- keycloak/realm-export.json | 108 +++++++------- ...260407_1200_004_migrate_scope_separator.py | 65 ++++++++ nextcloud_mcp_server/app.py | 14 +- .../auth/browser_oauth_routes.py | 2 +- .../auth/scope_authorization.py | 52 +++---- nextcloud_mcp_server/auth/token_broker.py | 4 +- nextcloud_mcp_server/cli.py | 4 +- nextcloud_mcp_server/models/auth.py | 44 +++--- nextcloud_mcp_server/observability/metrics.py | 2 +- nextcloud_mcp_server/server/auth_tools.py | 2 +- nextcloud_mcp_server/server/calendar.py | 32 ++-- nextcloud_mcp_server/server/collectives.py | 40 ++--- nextcloud_mcp_server/server/contacts.py | 14 +- nextcloud_mcp_server/server/cookbook.py | 26 ++-- nextcloud_mcp_server/server/deck.py | 50 +++---- nextcloud_mcp_server/server/news.py | 32 ++-- nextcloud_mcp_server/server/notes.py | 22 +-- nextcloud_mcp_server/server/oauth_tools.py | 16 +- nextcloud_mcp_server/server/semantic.py | 6 +- nextcloud_mcp_server/server/sharing.py | 10 +- nextcloud_mcp_server/server/tables.py | 12 +- nextcloud_mcp_server/server/webdav.py | 22 +-- nextcloud_mcp_server/vector/oauth_sync.py | 8 +- tests/conftest.py | 66 ++++----- tests/server/login_flow/test_dcr_lifecycle.py | 8 +- .../server/login_flow/test_dcr_token_type.py | 10 +- .../login_flow/test_scope_authorization.py | 124 ++++++++-------- tests/test_cli.py | 18 +-- tests/unit/api/test_access.py | 16 +- tests/unit/test_auth_tools.py | 24 +-- tests/unit/test_scope_authorization_stored.py | 4 +- tests/unit/test_scope_decorator.py | 16 +- tests/unit/test_token_broker.py | 6 +- 44 files changed, 724 insertions(+), 520 deletions(-) create mode 100644 docs/ADR-024-scope-separator-migration.md create mode 100644 nextcloud_mcp_server/alembic/versions/20260407_1200_004_migrate_scope_separator.py diff --git a/CLAUDE.md b/CLAUDE.md index 6b4811c0..05a5b617 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: diff --git a/app-hooks/before-starting/26-configure-astrolabe-oauth.sh b/app-hooks/before-starting/26-configure-astrolabe-oauth.sh index 3bfb30f5..acac7c83 100755 --- a/app-hooks/before-starting/26-configure-astrolabe-oauth.sh +++ b/app-hooks/before-starting/26-configure-astrolabe-oauth.sh @@ -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" \ diff --git a/charts/nextcloud-mcp-server/values.yaml b/charts/nextcloud-mcp-server/values.yaml index 2bea7913..03222e46 100644 --- a/charts/nextcloud-mcp-server/values.yaml +++ b/charts/nextcloud-mcp-server/values.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 18154298..650ed4a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -235,7 +235,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: diff --git a/docs/ADR-024-scope-separator-migration.md b/docs/ADR-024-scope-separator-migration.md new file mode 100644 index 00000000..bff9730a --- /dev/null +++ b/docs/ADR-024-scope-separator-migration.md @@ -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. diff --git a/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md b/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md index de2582a9..c3e59d21 100644 --- a/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md +++ b/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md @@ -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 diff --git a/docs/jwt-oauth-reference.md b/docs/jwt-oauth-reference.md index 4941af29..7f61e84d 100644 --- a/docs/jwt-oauth-reference.md +++ b/docs/jwt-oauth-reference.md @@ -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 diff --git a/docs/oauth-architecture.md b/docs/oauth-architecture.md index fa0b489f..d05b144b 100644 --- a/docs/oauth-architecture.md +++ b/docs/oauth-architecture.md @@ -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 │ │ │ │ │ │ │ 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 │ │ │ │ │ │ │ 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 ``` diff --git a/docs/oauth-troubleshooting.md b/docs/oauth-troubleshooting.md index 0fa9b633..11a3c8bd 100644 --- a/docs/oauth-troubleshooting.md +++ b/docs/oauth-troubleshooting.md @@ -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 \ - --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**: diff --git a/docs/semantic-search-architecture.md b/docs/semantic-search-architecture.md index 065c6979..f28b3f1e 100644 --- a/docs/semantic-search-architecture.md +++ b/docs/semantic-search-architecture.md @@ -298,7 +298,7 @@ sequenceDiagram participant NC as Nextcloud API User->>MCP: nc_semantic_search("machine learning") - MCP->>MCP: Check OAuth scope
(semantic:read) + MCP->>MCP: Check OAuth scope
(semantic.read) MCP->>Ollama: Generate query embedding Ollama-->>MCP: Query vector (768-dim) MCP->>Qdrant: Search similar vectors
(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 diff --git a/docs/testing-oidc-consent.md b/docs/testing-oidc-consent.md index 1d31dc98..8c641b2e 100644 --- a/docs/testing-oidc-consent.md +++ b/docs/testing-oidc-consent.md @@ -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 diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 0f585526..eee43188 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -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" ] } diff --git a/nextcloud_mcp_server/alembic/versions/20260407_1200_004_migrate_scope_separator.py b/nextcloud_mcp_server/alembic/versions/20260407_1200_004_migrate_scope_separator.py new file mode 100644 index 00000000..b7b1e991 --- /dev/null +++ b/nextcloud_mcp_server/alembic/versions/20260407_1200_004_migrate_scope_separator.py @@ -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 + """ + ) diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index b58b2849..750727fa 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -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 diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index 1facca17..b172269f 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -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" diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index b959a068..4f9d5f40 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -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: diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index 7e11c198..27813526 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -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( diff --git a/nextcloud_mcp_server/cli.py b/nextcloud_mcp_server/cli.py index 41f01caa..4fcf8612 100644 --- a/nextcloud_mcp_server/cli.py +++ b/nextcloud_mcp_server/cli.py @@ -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 \\ diff --git a/nextcloud_mcp_server/models/auth.py b/nextcloud_mcp_server/models/auth.py index e8f1c707..45a27f68 100644 --- a/nextcloud_mcp_server/models/auth.py +++ b/nextcloud_mcp_server/models/auth.py @@ -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", } ) diff --git a/nextcloud_mcp_server/observability/metrics.py b/nextcloud_mcp_server/observability/metrics.py index 587336cb..f33e8415 100644 --- a/nextcloud_mcp_server/observability/metrics.py +++ b/nextcloud_mcp_server/observability/metrics.py @@ -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(...): ... diff --git a/nextcloud_mcp_server/server/auth_tools.py b/nextcloud_mcp_server/server/auth_tools.py index f6aa99f7..305b5950 100644 --- a/nextcloud_mcp_server/server/auth_tools.py +++ b/nextcloud_mcp_server/server/auth_tools.py @@ -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: diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 8881186c..01bde47e 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -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, diff --git a/nextcloud_mcp_server/server/collectives.py b/nextcloud_mcp_server/server/collectives.py index cd0dbd66..01c8af2c 100644 --- a/nextcloud_mcp_server/server/collectives.py +++ b/nextcloud_mcp_server/server/collectives.py @@ -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 diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index 33254d35..2becd064 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -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 = "" diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 79e8e884..d5191c33 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -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. diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index c8d589d2..f9ae9dc3 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -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 diff --git a/nextcloud_mcp_server/server/news.py b/nextcloud_mcp_server/server/news.py index c392f44c..0fdfd895 100644 --- a/nextcloud_mcp_server/server/news.py +++ b/nextcloud_mcp_server/server/news.py @@ -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. """ diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 5088fc26..1e32e0c7 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -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""" diff --git a/nextcloud_mcp_server/server/oauth_tools.py b/nextcloud_mcp_server/server/oauth_tools.py index 75e261ef..f49c0ff6 100644 --- a/nextcloud_mcp_server/server/oauth_tools.py +++ b/nextcloud_mcp_server/server/oauth_tools.py @@ -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 diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index 204f646e..b8e28ef8 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -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. diff --git a/nextcloud_mcp_server/server/sharing.py b/nextcloud_mcp_server/server/sharing.py index 207810db..baebad85 100644 --- a/nextcloud_mcp_server/server/sharing.py +++ b/nextcloud_mcp_server/server/sharing.py @@ -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. diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py index 5a7f0715..e648a1f7 100644 --- a/nextcloud_mcp_server/server/tables.py +++ b/nextcloud_mcp_server/server/tables.py @@ -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""" diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index d09b2f0c..c89f3896 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -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 diff --git a/nextcloud_mcp_server/vector/oauth_sync.py b/nextcloud_mcp_server/vector/oauth_sync.py index 615977e1..8b4dcaae 100644 --- a/nextcloud_mcp_server/vector/oauth_sync.py +++ b/nextcloud_mcp_server/vector/oauth_sync.py @@ -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 ] diff --git a/tests/conftest.py b/tests/conftest.py index 74ac72ba..94ddb0f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, diff --git a/tests/server/login_flow/test_dcr_lifecycle.py b/tests/server/login_flow/test_dcr_lifecycle.py index 7c22c2dd..b7d71fd6 100644 --- a/tests/server/login_flow/test_dcr_lifecycle.py +++ b/tests/server/login_flow/test_dcr_lifecycle.py @@ -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" diff --git a/tests/server/login_flow/test_dcr_token_type.py b/tests/server/login_flow/test_dcr_token_type.py index 60174c85..79059e62 100644 --- a/tests/server/login_flow/test_dcr_token_type.py +++ b/tests/server/login_flow/test_dcr_token_type.py @@ -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", ) diff --git a/tests/server/login_flow/test_scope_authorization.py b/tests/server/login_flow/test_scope_authorization.py index 679c2206..ae68127d 100644 --- a/tests/server/login_flow/test_scope_authorization.py +++ b/tests/server/login_flow/test_scope_authorization.py @@ -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") diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a131b60..0481955f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" diff --git a/tests/unit/api/test_access.py b/tests/unit/api/test_access.py index 380168a8..6191ccf5 100644 --- a/tests/unit/api/test_access.py +++ b/tests/unit/api/test_access.py @@ -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() diff --git a/tests/unit/test_auth_tools.py b/tests/unit/test_auth_tools.py index 0947a888..25e609fd 100644 --- a/tests/unit/test_auth_tools.py +++ b/tests/unit/test_auth_tools.py @@ -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")] diff --git a/tests/unit/test_scope_authorization_stored.py b/tests/unit/test_scope_authorization_stored.py index 0b8ceabc..a7315e70 100644 --- a/tests/unit/test_scope_authorization_stored.py +++ b/tests/unit/test_scope_authorization_stored.py @@ -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(): diff --git a/tests/unit/test_scope_decorator.py b/tests/unit/test_scope_decorator.py index 969e546c..ae03caa6 100644 --- a/tests/unit/test_scope_decorator.py +++ b/tests/unit/test_scope_decorator.py @@ -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) diff --git a/tests/unit/test_token_broker.py b/tests/unit/test_token_broker.py index 8d9ba3d7..32105b2e 100644 --- a/tests/unit/test_token_broker.py +++ b/tests/unit/test_token_broker.py @@ -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 )