Merge pull request #682 from cbcoutinho/refactor/scope-separator-colon-to-dot
refactor: change OAuth scope separator from colon to dot
This commit is contained in:
@@ -373,7 +373,7 @@ MCP sampling allows servers to request LLM completions from their clients. This
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_scopes("notes.read")
|
||||
async def nc_notes_semantic_search_answer(
|
||||
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
|
||||
) -> SamplingSearchResponse:
|
||||
|
||||
@@ -40,7 +40,7 @@ CLIENT_ID="astrolabeMcpClientOAuth00000000000"
|
||||
REDIRECT_URI="${NC_EXTERNAL_URL}/apps/astrolabe/oauth/callback"
|
||||
|
||||
# All scopes the MCP server supports (must match DCR scopes in app.py)
|
||||
ALLOWED_SCOPES="openid profile email offline_access notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write collectives:read collectives:write semantic:read"
|
||||
ALLOWED_SCOPES="openid profile email offline_access notes.read notes.write calendar.read calendar.write todo.read todo.write contacts.read contacts.write cookbook.read cookbook.write deck.read deck.write tables.read tables.write files.read files.write sharing.read sharing.write news.read news.write collectives.read collectives.write semantic.read"
|
||||
|
||||
# Create OAuth client
|
||||
CLIENT_JSON=$(php occ oidc:create "Astrolabe" \
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -76,7 +76,7 @@ Both modes maintain the critical separation: **refresh tokens are never used for
|
||||
- ✅ Generated **on-demand** during tool execution
|
||||
- ✅ **Ephemeral** - used only for current operation
|
||||
- ✅ **NOT stored** - discarded after use
|
||||
- ✅ **Limited scopes** - only what tool needs (e.g., `notes:read` for search)
|
||||
- ✅ **Limited scopes** - only what tool needs (e.g., `notes.read` for search)
|
||||
- ✅ **Short-lived** - expires quickly (e.g., 5 minutes)
|
||||
|
||||
**Background Jobs (Offline Operations)**:
|
||||
@@ -202,7 +202,7 @@ Tools use the same pattern regardless of token acquisition mode:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
|
||||
@require_scopes("notes.read") # Soft-scope enforced by MCP server, not Nextcloud
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content."""
|
||||
@@ -333,7 +333,7 @@ When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ep
|
||||
|
||||
## Nextcloud Scope Limitation
|
||||
|
||||
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
|
||||
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes.read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
|
||||
|
||||
This means:
|
||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
||||
|
||||
+43
-43
@@ -28,7 +28,7 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
||||
### Key Features
|
||||
|
||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||
- ✅ **Custom Scopes** - `mcp:notes:read` and `mcp:notes:write` for read/write access control
|
||||
- ✅ **Custom Scopes** - `mcp:notes.read` and `mcp:notes.write` for read/write access control
|
||||
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||
@@ -38,8 +38,8 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
||||
|
||||
| Scope | Description | Tool Count |
|
||||
|-------|-------------|------------|
|
||||
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `mcp:notes:write` | Write access to create/modify/delete data | 54 tools |
|
||||
| `mcp:notes.read` | Read-only access to Nextcloud data | 36 tools |
|
||||
| `mcp:notes.write` | Write access to create/modify/delete data | 54 tools |
|
||||
|
||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||
|
||||
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
|
||||
"aud": "client_id",
|
||||
"exp": 1234567890,
|
||||
"iat": 1234567890,
|
||||
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
||||
"scope": "openid profile email mcp:notes.read mcp:notes.write",
|
||||
"client_id": "...",
|
||||
"jti": "..."
|
||||
}
|
||||
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
| Scope | Operations | Examples |
|
||||
|-------|------------|----------|
|
||||
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
| `mcp:notes.read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||
| `mcp:notes.write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||
|
||||
### Standard OIDC Scopes
|
||||
|
||||
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
||||
|
||||
**Full Access:**
|
||||
```
|
||||
openid profile email mcp:notes:read mcp:notes:write
|
||||
openid profile email mcp:notes.read mcp:notes.write
|
||||
```
|
||||
|
||||
**Read-Only:**
|
||||
```
|
||||
openid profile email mcp:notes:read
|
||||
openid profile email mcp:notes.read
|
||||
```
|
||||
|
||||
**No Custom Scopes (OIDC only):**
|
||||
@@ -150,32 +150,32 @@ All 90 MCP tools are decorated with scope requirements:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("mcp:notes:read")
|
||||
@require_scopes("mcp:notes.read")
|
||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||
"""Get a note by ID (requires mcp:notes:read scope)"""
|
||||
"""Get a note by ID (requires mcp:notes.read scope)"""
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("mcp:notes:write")
|
||||
@require_scopes("mcp:notes.write")
|
||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||
"""Create a note (requires mcp:notes:write scope)"""
|
||||
"""Create a note (requires mcp:notes.write scope)"""
|
||||
...
|
||||
```
|
||||
|
||||
**Coverage:**
|
||||
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
||||
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes.read")`
|
||||
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes.write")`
|
||||
- ✅ 90/90 tools covered (100%)
|
||||
|
||||
### Dynamic Tool Filtering
|
||||
|
||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||
|
||||
**Token with `mcp:notes:read` only:**
|
||||
**Token with `mcp:notes.read` only:**
|
||||
- `list_tools()` returns 36 read-only tools
|
||||
- Write tools are hidden from the tool list
|
||||
|
||||
**Token with `mcp:notes:write` only:**
|
||||
**Token with `mcp:notes.write` only:**
|
||||
- `list_tools()` returns 54 write-only tools
|
||||
- Read tools are hidden from the tool list
|
||||
|
||||
@@ -183,7 +183,7 @@ The MCP server implements **dynamic tool filtering** - users only see tools they
|
||||
- `list_tools()` returns all 90 tools
|
||||
|
||||
**Token with no custom scopes:**
|
||||
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
- `list_tools()` returns 0 tools (all require `mcp:notes.read` or `mcp:notes.write`)
|
||||
|
||||
**BasicAuth mode:**
|
||||
- `list_tools()` returns all 90 tools (no filtering)
|
||||
@@ -197,7 +197,7 @@ When a tool is called without required scopes, the server returns a `403 Forbidd
|
||||
```http
|
||||
HTTP/1.1 403 Forbidden
|
||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||
scope="mcp:notes:write",
|
||||
scope="mcp:notes.write",
|
||||
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
@@ -213,7 +213,7 @@ The server implements RFC 9728's Protected Resource Metadata endpoint:
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8001/mcp",
|
||||
"scopes_supported": ["mcp:notes:read", "mcp:notes:write"],
|
||||
"scopes_supported": ["mcp:notes.read", "mcp:notes.write"],
|
||||
"authorization_servers": ["http://localhost:8080"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
@@ -250,7 +250,7 @@ mcp-oauth:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
||||
```
|
||||
@@ -286,7 +286,7 @@ mcp-oauth:
|
||||
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
|
||||
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes.read mcp:notes.write"` |
|
||||
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||
|
||||
### Dynamic Client Registration (DCR)
|
||||
@@ -320,7 +320,7 @@ DCR automatically configures the client based on environment variables:
|
||||
# Minimal DCR configuration (no credentials needed!)
|
||||
export NEXTCLOUD_HOST=http://localhost:8080
|
||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes:read mcp:notes:write"
|
||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email mcp:notes.read mcp:notes.write"
|
||||
export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
|
||||
```
|
||||
|
||||
@@ -362,7 +362,7 @@ Manual client creation is **optional** but may be preferred when:
|
||||
```bash
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
|
||||
"Nextcloud MCP Server" \
|
||||
"http://localhost:8000/oauth/callback"
|
||||
```
|
||||
@@ -373,7 +373,7 @@ docker compose exec app php occ oidc:create \
|
||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||
"token_type": "jwt",
|
||||
"allowed_scopes": "openid profile email mcp:notes:read mcp:notes:write"
|
||||
"allowed_scopes": "openid profile email mcp:notes.read mcp:notes.write"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -406,7 +406,7 @@ When credentials are provided via environment variables or storage file, **DCR i
|
||||
│ │
|
||||
│ JWT Access Token │
|
||||
│ { │
|
||||
│ "scope": "openid mcp:notes:read mcp:notes:write" │
|
||||
│ "scope": "openid mcp:notes.read mcp:notes.write" │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ │
|
||||
@@ -463,7 +463,7 @@ When credentials are provided via environment variables or storage file, **DCR i
|
||||
|
||||
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
||||
- Advertises `["mcp:notes.read", "mcp:notes.write"]`
|
||||
- RFC 9728 compliant
|
||||
|
||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||
@@ -499,7 +499,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
|
||||
│ ├─ Authenticate with client credentials
|
||||
│ ├─ Response contains:
|
||||
│ │ • active: true/false
|
||||
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
||||
│ │ • scope: "openid mcp:notes.read mcp:notes.write"
|
||||
│ │ • sub, exp, iat, client_id
|
||||
│ ├─ Extract scopes from response
|
||||
│ └─ Success: Return AccessToken
|
||||
@@ -553,7 +553,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with only OIDC defaults (`openid profile email`)
|
||||
**Expected:** 0 tools returned (all require `mcp:notes:read` or `mcp:notes:write`)
|
||||
**Expected:** 0 tools returned (all require `mcp:notes.read` or `mcp:notes.write`)
|
||||
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||
|
||||
#### 2. Read-Only Access (36 tools)
|
||||
@@ -561,7 +561,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_with_no_custom_
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_read_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `mcp:notes:read` only
|
||||
**Scenario:** JWT token with `mcp:notes.read` only
|
||||
**Expected:** 36 read-only tools visible, write tools hidden
|
||||
**Verifies:** Read tools accessible, write tools filtered out
|
||||
|
||||
@@ -570,7 +570,7 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_write_only -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with `mcp:notes:write` only
|
||||
**Scenario:** JWT token with `mcp:notes.write` only
|
||||
**Expected:** 54 write tools visible, read tools hidden
|
||||
**Verifies:** Write tools accessible, read tools filtered out
|
||||
|
||||
@@ -579,21 +579,21 @@ uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenari
|
||||
uv run pytest tests/server/test_scope_authorization.py::test_jwt_consent_scenarios_full_access -v
|
||||
```
|
||||
|
||||
**Scenario:** JWT token with both `mcp:notes:read` and `mcp:notes:write`
|
||||
**Scenario:** JWT token with both `mcp:notes.read` and `mcp:notes.write`
|
||||
**Expected:** All 90 tools visible
|
||||
**Verifies:** Full access when user grants all custom scopes
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
**OAuth Client Fixtures:**
|
||||
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
||||
- `read_only_oauth_client_credentials` - Client with `mcp:notes.read` only
|
||||
- `write_only_oauth_client_credentials` - Client with `mcp:notes.write` only
|
||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||
|
||||
**Token Fixtures:**
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes:write`
|
||||
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes.read`
|
||||
- `playwright_oauth_token_write_only` - Obtains token with `mcp:notes.write`
|
||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||
|
||||
@@ -681,14 +681,14 @@ docker compose exec app php occ oidc:list
|
||||
# If empty, recreate client with --allowed_scopes
|
||||
docker compose exec app php occ oidc:create \
|
||||
--token_type=jwt \
|
||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
||||
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
|
||||
"Client Name" \
|
||||
"http://callback/url"
|
||||
```
|
||||
|
||||
### Issue: All Tools Visible Despite Read-Only Token
|
||||
|
||||
**Symptom:** User with `mcp:notes:read` token can see all 90 tools including write tools
|
||||
**Symptom:** User with `mcp:notes.read` token can see all 90 tools including write tools
|
||||
|
||||
**Cause:** Server running in BasicAuth mode, not OAuth mode
|
||||
|
||||
@@ -716,7 +716,7 @@ DCR **now properly sets `allowed_scopes`** when the `scope` parameter is provide
|
||||
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||
|
||||
# Should show your requested scopes (e.g., "openid profile email mcp:notes:read mcp:notes:write")
|
||||
# Should show your requested scopes (e.g., "openid profile email mcp:notes.read mcp:notes.write")
|
||||
```
|
||||
|
||||
**If scopes are missing:**
|
||||
@@ -812,7 +812,7 @@ mcp-oauth:
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
|
||||
ports:
|
||||
- "8001:8001"
|
||||
```
|
||||
@@ -846,22 +846,22 @@ mcp-oauth:
|
||||
```bash
|
||||
# Success
|
||||
INFO JWT verified successfully for user: admin
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes.read', 'mcp:notes.write'}
|
||||
|
||||
# Failures
|
||||
WARNING JWT issuer validation failed: Invalid issuer
|
||||
WARNING Missing required scopes: mcp:notes:write
|
||||
WARNING Missing required scopes: mcp:notes.write
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes:read` and `mcp:notes:write` (not per-app scopes)
|
||||
1. **No Fine-Grained Scopes** - Only coarse `mcp:notes.read` and `mcp:notes.write` (not per-app scopes)
|
||||
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
**Potential Improvements:**
|
||||
- Per-app scopes (`nc:notes:read`, `nc:calendar:write`)
|
||||
- Per-app scopes (`nc:notes.read`, `nc:calendar.write`)
|
||||
- Resource-level filtering (apply to MCP resources, not just tools)
|
||||
- Automatic scope discovery from decorated tools
|
||||
- Admin UI for scope management
|
||||
|
||||
+43
-43
@@ -89,7 +89,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
||||
│ client_id=xxx │ │
|
||||
│ &code_challenge=abc... │ │
|
||||
│ &code_challenge_method=S256 │ │
|
||||
│ &scope=openid notes:read ... │ │
|
||||
│ &scope=openid notes.read ... │ │
|
||||
│ │ │
|
||||
│ 2c. User consent page │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
@@ -111,7 +111,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
||||
│ 2g. Access token (JWT/opaque) │ │
|
||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||
│ {access_token, token_type, │ │
|
||||
│ scope: "openid notes:read...") │ ← User's granted scopes │
|
||||
│ scope: "openid notes.read...") │ ← User's granted scopes │
|
||||
│ │ │
|
||||
|
||||
|
||||
@@ -140,11 +140,11 @@ Phase 3: MCP Tool Access (Scope-based Authorization)
|
||||
│ │ │
|
||||
│ 3e. Call tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes:read") │
|
||||
│ nc_notes_get_note(note_id=1) │ ← @require_scopes("notes.read") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 3f. Scope check PASSED │
|
||||
│ │ ✓ Token has notes:read │
|
||||
│ │ ✓ Token has notes.read │
|
||||
│ │ │
|
||||
│ │ 3g. Nextcloud API call │
|
||||
│ ├────────────────────────────────────>│
|
||||
@@ -168,17 +168,17 @@ Insufficient Scope Example (Step-Up Authorization)
|
||||
|
||||
│ 4a. Call write tool │ │
|
||||
├─────────────────────────────────>│ │
|
||||
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
|
||||
│ nc_notes_create_note(...) │ ← @require_scopes("notes.write") │
|
||||
│ Authorization: Bearer <token> │ │
|
||||
│ │ │
|
||||
│ │ 4b. Scope check FAILED │
|
||||
│ │ ✗ Token only has notes:read │
|
||||
│ │ ✗ Token only has notes.read │
|
||||
│ │ │
|
||||
│ 4c. 403 Insufficient Scope │ │
|
||||
│<─────────────────────────────────┤ │
|
||||
│ WWW-Authenticate: Bearer │ │
|
||||
│ error="insufficient_scope", │ │
|
||||
│ scope="notes:write", │ │
|
||||
│ scope="notes.write", │ │
|
||||
│ resource_metadata="..." │ │
|
||||
│ │ │
|
||||
│ → Client can re-authorize with │ │
|
||||
@@ -364,7 +364,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
||||
- `client_id`: OAuth client ID
|
||||
- `code_challenge`: SHA256 hash of verifier
|
||||
- `code_challenge_method`: `S256`
|
||||
- `scope`: Requested scopes (e.g., `openid notes:read notes:write`)
|
||||
- `scope`: Requested scopes (e.g., `openid notes.read notes.write`)
|
||||
- `redirect_uri`: MCP server callback URL
|
||||
|
||||
3. **User Consent**
|
||||
@@ -409,7 +409,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
||||
3. **Dynamic Tool Filtering**
|
||||
- Server compares token scopes with each tool's `@require_scopes`
|
||||
- Only returns tools where user has all required scopes
|
||||
- Example: Token with `notes:read` sees 4 read tools, not 3 write tools
|
||||
- Example: Token with `notes.read` sees 4 read tools, not 3 write tools
|
||||
|
||||
4. **Filtered Tool List**
|
||||
- Client receives only tools they can use
|
||||
@@ -420,7 +420,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
||||
|
||||
2. **Scope Validation**
|
||||
- `@require_scopes` decorator extracts token scopes
|
||||
- Verifies token contains required scope (e.g., `notes:read`)
|
||||
- Verifies token contains required scope (e.g., `notes.read`)
|
||||
- If missing → 403 with `WWW-Authenticate` header (step-up auth)
|
||||
- If present → continues execution
|
||||
|
||||
@@ -443,26 +443,26 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
||||
|
||||
**Steps**:
|
||||
1. **Tool Call with Insufficient Scopes**
|
||||
- User calls `nc_notes_create_note` (requires `notes:write`)
|
||||
- But token only has `notes:read`
|
||||
- User calls `nc_notes_create_note` (requires `notes.write`)
|
||||
- But token only has `notes.read`
|
||||
|
||||
2. **Scope Validation Fails**
|
||||
- `@require_scopes("notes:write")` decorator checks token
|
||||
- Finds `notes:write` missing
|
||||
- `@require_scopes("notes.write")` decorator checks token
|
||||
- Finds `notes.write` missing
|
||||
|
||||
3. **403 Response with Challenge**
|
||||
- Returns `403 Forbidden`
|
||||
- Includes `WWW-Authenticate` header:
|
||||
```
|
||||
Bearer error="insufficient_scope",
|
||||
scope="notes:write",
|
||||
scope="notes.write",
|
||||
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
4. **Client Re-Authorization** (Optional)
|
||||
- Client can initiate new OAuth flow requesting additional scopes
|
||||
- User re-consents with expanded permissions
|
||||
- New token includes both `notes:read` and `notes:write`
|
||||
- New token includes both `notes.read` and `notes.write`
|
||||
|
||||
**Result**: User can dynamically upgrade permissions without full re-authentication
|
||||
|
||||
@@ -599,46 +599,46 @@ The server supports the following OAuth scopes, organized by Nextcloud app:
|
||||
- `email` - Access to user email address (required)
|
||||
|
||||
#### Notes App
|
||||
- `notes:read` - Read notes, search notes, get note attachments
|
||||
- `notes:write` - Create, update, append to, and delete notes
|
||||
- `notes.read` - Read notes, search notes, get note attachments
|
||||
- `notes.write` - Create, update, append to, and delete notes
|
||||
|
||||
#### Calendar App
|
||||
- `calendar:read` - List calendars, read events, search events
|
||||
- `calendar:write` - Create, update, and delete calendars and events
|
||||
- `calendar.read` - List calendars, read events, search events
|
||||
- `calendar.write` - Create, update, and delete calendars and events
|
||||
|
||||
#### Calendar Tasks (VTODO)
|
||||
- `todo:read` - List and read CalDAV tasks
|
||||
- `todo:write` - Create, update, and delete CalDAV tasks
|
||||
- `todo.read` - List and read CalDAV tasks
|
||||
- `todo.write` - Create, update, and delete CalDAV tasks
|
||||
|
||||
#### Contacts App
|
||||
- `contacts:read` - List address books and read contacts (CardDAV)
|
||||
- `contacts:write` - Create, update, and delete address books and contacts
|
||||
- `contacts.read` - List address books and read contacts (CardDAV)
|
||||
- `contacts.write` - Create, update, and delete address books and contacts
|
||||
|
||||
#### Cookbook App
|
||||
- `cookbook:read` - Read recipes, search recipes
|
||||
- `cookbook:write` - Create, update, and delete recipes
|
||||
- `cookbook.read` - Read recipes, search recipes
|
||||
- `cookbook.write` - Create, update, and delete recipes
|
||||
|
||||
#### Deck App
|
||||
- `deck:read` - List boards, stacks, cards, and labels
|
||||
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
|
||||
- `deck.read` - List boards, stacks, cards, and labels
|
||||
- `deck.write` - Create, update, and delete boards, stacks, cards, and labels
|
||||
|
||||
#### Tables App
|
||||
- `tables:read` - List tables and read rows
|
||||
- `tables:write` - Create, update, and delete rows in tables
|
||||
- `tables.read` - List tables and read rows
|
||||
- `tables.write` - Create, update, and delete rows in tables
|
||||
|
||||
#### Files (WebDAV)
|
||||
- `files:read` - List files, read file contents, search files
|
||||
- `files:write` - Upload, update, move, copy, and delete files
|
||||
- `files.read` - List files, read file contents, search files
|
||||
- `files.write` - Upload, update, move, copy, and delete files
|
||||
|
||||
#### Sharing
|
||||
- `sharing:read` - List shares and read share information
|
||||
- `sharing:write` - Create, update, and delete shares
|
||||
- `sharing.read` - List shares and read share information
|
||||
- `sharing.write` - Create, update, and delete shares
|
||||
|
||||
#### Semantic Search (Multi-App Vector Database)
|
||||
- `semantic:read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
|
||||
- `semantic:write` - Enable/disable background vector synchronization, manage indexing settings
|
||||
- `semantic.read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
|
||||
- `semantic.write` - Enable/disable background vector synchronization, manage indexing settings
|
||||
|
||||
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes:read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
|
||||
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes.read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
@@ -652,7 +652,7 @@ curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
|
||||
# Response includes dynamically discovered scopes
|
||||
{
|
||||
"resource": "http://localhost:8000/mcp",
|
||||
"scopes_supported": ["openid", "profile", "email", "notes:read", ...],
|
||||
"scopes_supported": ["openid", "profile", "email", "notes.read", ...],
|
||||
"authorization_servers": ["https://nextcloud.example.com"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
"resource_signing_alg_values_supported": ["RS256"]
|
||||
@@ -669,7 +669,7 @@ Tools are decorated with `@require_scopes()` to declare their required permissio
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_scopes("notes.read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
"""Get a specific note by ID"""
|
||||
# Implementation
|
||||
@@ -681,7 +681,7 @@ During OAuth client registration (dynamic or manual), clients request a set of s
|
||||
|
||||
**Environment Variable**:
|
||||
```bash
|
||||
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..."
|
||||
NEXTCLOUD_OIDC_SCOPES="openid profile email notes.read notes.write calendar.read calendar.write ..."
|
||||
```
|
||||
|
||||
**Default**: All supported scopes (recommended for development)
|
||||
@@ -695,7 +695,7 @@ The server supports OAuth step-up authorization (RFC 8693). If a user attempts t
|
||||
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
|
||||
2. Response includes `WWW-Authenticate` header listing missing scopes:
|
||||
```
|
||||
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..."
|
||||
WWW-Authenticate: Bearer error="insufficient_scope", scope="notes.write", resource_metadata="..."
|
||||
```
|
||||
3. Client can re-authorize with additional scopes
|
||||
|
||||
@@ -708,9 +708,9 @@ All scope enforcement happens at two levels:
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# User token has: ["openid", "profile", "email", "notes:read"]
|
||||
# User token has: ["openid", "profile", "email", "notes.read"]
|
||||
# They will see: 4 read-only notes tools
|
||||
# They will NOT see: 3 write notes tools (notes:write required)
|
||||
# They will NOT see: 3 write notes tools (notes.write required)
|
||||
# Attempting to call a write tool returns 403 Forbidden
|
||||
```
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ http://localhost:8000/oauth/callback
|
||||
**Symptoms**:
|
||||
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||
- Only Notes tools are available (7 tools instead of 90+)
|
||||
- Token scopes show only `mcp:notes:read` and `mcp:notes:write`
|
||||
- Token scopes show only `mcp:notes.read` and `mcp:notes.write`
|
||||
|
||||
**Cause**: During the OAuth consent flow, the user only granted access to Notes scopes, or the client only requested those scopes.
|
||||
|
||||
@@ -449,7 +449,7 @@ When reconnecting, you'll see a consent screen listing all available scopes. Mak
|
||||
```bash
|
||||
# Update allowed scopes for an existing client
|
||||
php occ oidc:update <client_id> \
|
||||
--allowed-scopes "openid profile email mcp:notes:read mcp:notes:write mcp:calendar:read mcp:calendar:write mcp:contacts:read mcp:contacts:write mcp:cookbook:read mcp:cookbook:write mcp:deck:read mcp:deck:write mcp:tables:read mcp:tables:write mcp:files:read mcp:files:write mcp:sharing:read mcp:sharing:write"
|
||||
--allowed-scopes "openid profile email mcp:notes.read mcp:notes.write mcp:calendar.read mcp:calendar.write mcp:contacts.read mcp:contacts.write mcp:cookbook.read mcp:cookbook.write mcp:deck.read mcp:deck.write mcp:tables.read mcp:tables.write mcp:files.read mcp:files.write mcp:sharing.read mcp:sharing.write"
|
||||
|
||||
# User will need to reconnect to get new token with updated scopes
|
||||
```
|
||||
@@ -463,14 +463,14 @@ curl http://localhost:8001/.well-known/oauth-protected-resource | jq '.scopes_su
|
||||
|
||||
# Should show all 16 scope categories:
|
||||
# - openid
|
||||
# - mcp:notes:read, mcp:notes:write
|
||||
# - mcp:calendar:read, mcp:calendar:write
|
||||
# - mcp:contacts:read, mcp:contacts:write
|
||||
# - mcp:cookbook:read, mcp:cookbook:write
|
||||
# - mcp:deck:read, mcp:deck:write
|
||||
# - mcp:tables:read, mcp:tables:write
|
||||
# - mcp:files:read, mcp:files:write
|
||||
# - mcp:sharing:read, mcp:sharing:write
|
||||
# - mcp:notes.read, mcp:notes.write
|
||||
# - mcp:calendar.read, mcp:calendar.write
|
||||
# - mcp:contacts.read, mcp:contacts.write
|
||||
# - mcp:cookbook.read, mcp:cookbook.write
|
||||
# - mcp:deck.read, mcp:deck.write
|
||||
# - mcp:tables.read, mcp:tables.write
|
||||
# - mcp:files.read, mcp:files.write
|
||||
# - mcp:sharing.read, mcp:sharing.write
|
||||
```
|
||||
|
||||
**Understanding Scope Filtering**:
|
||||
|
||||
@@ -298,7 +298,7 @@ sequenceDiagram
|
||||
participant NC as Nextcloud API
|
||||
|
||||
User->>MCP: nc_semantic_search("machine learning")
|
||||
MCP->>MCP: Check OAuth scope<br/>(semantic:read)
|
||||
MCP->>MCP: Check OAuth scope<br/>(semantic.read)
|
||||
MCP->>Ollama: Generate query embedding
|
||||
Ollama-->>MCP: Query vector (768-dim)
|
||||
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
|
||||
@@ -319,7 +319,7 @@ sequenceDiagram
|
||||
### Dual-Phase Authorization
|
||||
|
||||
**Phase 1: OAuth Scope Check**
|
||||
- Verify user has `semantic:read` scope
|
||||
- Verify user has `semantic.read` scope
|
||||
- Rejects unauthorized users immediately
|
||||
|
||||
**Phase 2: Per-Document Verification**
|
||||
@@ -419,19 +419,19 @@ except Exception as e:
|
||||
|
||||
### OAuth Scopes
|
||||
|
||||
**`semantic:read`** - Search permission
|
||||
**`semantic.read`** - Search permission
|
||||
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
|
||||
- Does NOT grant access to documents (verified via app APIs)
|
||||
- Required for any semantic search operation
|
||||
|
||||
**`semantic:write`** - Sync control permission
|
||||
**`semantic.write`** - Sync control permission
|
||||
- Allows enabling/disabling background sync (`provision_vector_sync`, `deprovision_vector_sync`)
|
||||
- Controls whether user's documents are indexed
|
||||
- Currently not implemented in OAuth mode (BasicAuth only)
|
||||
|
||||
### Dual-Phase Authorization Pattern
|
||||
|
||||
**Phase 1: Scope Check** (semantic:read)
|
||||
**Phase 1: Scope Check** (semantic.read)
|
||||
- Verifies user authorized to search
|
||||
- Prevents unauthorized vector database access
|
||||
|
||||
@@ -482,7 +482,7 @@ except Exception as e:
|
||||
- Credentials stored in `.env` file (secure server access required)
|
||||
|
||||
**OAuth:**
|
||||
- Client authenticates with `semantic:read` scope
|
||||
- Client authenticates with `semantic.read` scope
|
||||
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
|
||||
- Background sync only works for users who provisioned access
|
||||
- More secure: tokens expire, user controls access
|
||||
|
||||
@@ -190,7 +190,7 @@ docker compose logs -f mcp-oauth
|
||||
1. Get client_id from the JWT client JSON
|
||||
2. Visit in browser:
|
||||
```
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes:read+mcp:notes:write&state=test123
|
||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost:8001/oauth/callback&scope=openid+profile+email+mcp:notes.read+mcp:notes.write&state=test123
|
||||
```
|
||||
|
||||
### 3. Expected Behavior
|
||||
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
|
||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||
- ✓ Profile information (profile)
|
||||
- ✓ Email address (email)
|
||||
- ✓ mcp:notes:read (custom scope, shown as-is)
|
||||
- ✓ mcp:notes:write (custom scope, shown as-is)
|
||||
- ✓ mcp:notes.read (custom scope, shown as-is)
|
||||
- ✓ mcp:notes.write (custom scope, shown as-is)
|
||||
- "Allow" and "Deny" buttons
|
||||
3. User selects scopes and clicks "Allow"
|
||||
4. Authorization proceeds with selected scopes
|
||||
|
||||
+54
-54
@@ -383,24 +383,24 @@
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt",
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"todo:read",
|
||||
"todo:write"
|
||||
"notes.read",
|
||||
"notes.write",
|
||||
"calendar.read",
|
||||
"calendar.write",
|
||||
"contacts.read",
|
||||
"contacts.write",
|
||||
"cookbook.read",
|
||||
"cookbook.write",
|
||||
"deck.read",
|
||||
"deck.write",
|
||||
"tables.read",
|
||||
"tables.write",
|
||||
"files.read",
|
||||
"files.write",
|
||||
"sharing.read",
|
||||
"sharing.write",
|
||||
"todo.read",
|
||||
"todo.write"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -573,7 +573,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "notes:read",
|
||||
"name": "notes.read",
|
||||
"description": "Nextcloud Notes read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -583,7 +583,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "notes:write",
|
||||
"name": "notes.write",
|
||||
"description": "Nextcloud Notes write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -593,7 +593,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar:read",
|
||||
"name": "calendar.read",
|
||||
"description": "Nextcloud Calendar read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -603,7 +603,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar:write",
|
||||
"name": "calendar.write",
|
||||
"description": "Nextcloud Calendar write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -613,7 +613,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contacts:read",
|
||||
"name": "contacts.read",
|
||||
"description": "Nextcloud Contacts read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -623,7 +623,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "contacts:write",
|
||||
"name": "contacts.write",
|
||||
"description": "Nextcloud Contacts write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -633,7 +633,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cookbook:read",
|
||||
"name": "cookbook.read",
|
||||
"description": "Nextcloud Cookbook read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -643,7 +643,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cookbook:write",
|
||||
"name": "cookbook.write",
|
||||
"description": "Nextcloud Cookbook write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -653,7 +653,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deck:read",
|
||||
"name": "deck.read",
|
||||
"description": "Nextcloud Deck read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -663,7 +663,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "deck:write",
|
||||
"name": "deck.write",
|
||||
"description": "Nextcloud Deck write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -673,7 +673,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tables:read",
|
||||
"name": "tables.read",
|
||||
"description": "Nextcloud Tables read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -683,7 +683,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tables:write",
|
||||
"name": "tables.write",
|
||||
"description": "Nextcloud Tables write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -693,7 +693,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files:read",
|
||||
"name": "files.read",
|
||||
"description": "Nextcloud Files read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -703,7 +703,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files:write",
|
||||
"name": "files.write",
|
||||
"description": "Nextcloud Files write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -713,7 +713,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sharing:read",
|
||||
"name": "sharing.read",
|
||||
"description": "Nextcloud Sharing read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -723,7 +723,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sharing:write",
|
||||
"name": "sharing.write",
|
||||
"description": "Nextcloud Sharing write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -733,7 +733,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo:read",
|
||||
"name": "todo.read",
|
||||
"description": "Nextcloud Tasks/Todo read access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -743,7 +743,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo:write",
|
||||
"name": "todo.write",
|
||||
"description": "Nextcloud Tasks/Todo write access",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
@@ -830,23 +830,23 @@
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"todo:read",
|
||||
"todo:write"
|
||||
"notes.read",
|
||||
"notes.write",
|
||||
"calendar.read",
|
||||
"calendar.write",
|
||||
"contacts.read",
|
||||
"contacts.write",
|
||||
"cookbook.read",
|
||||
"cookbook.write",
|
||||
"deck.read",
|
||||
"deck.write",
|
||||
"tables.read",
|
||||
"tables.write",
|
||||
"files.read",
|
||||
"files.write",
|
||||
"sharing.read",
|
||||
"sharing.write",
|
||||
"todo.read",
|
||||
"todo.write"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Migrate scope separator from colon to dot
|
||||
|
||||
Many identity providers reject ':' in OAuth scope names. This migration
|
||||
updates stored scope strings from the old 'resource:action' format to
|
||||
'resource.action' (e.g., 'notes:read' -> 'notes.read').
|
||||
|
||||
See ADR-023 for rationale.
|
||||
|
||||
Revision ID: 004
|
||||
Revises: 003
|
||||
Create Date: 2026-04-07 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "004"
|
||||
down_revision = "003"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Replace colon separator with dot in stored scope strings."""
|
||||
|
||||
# Update scopes in app_passwords (JSON array of scope strings)
|
||||
# Only ':' characters in a JSON array like '["notes:read","calendar:write"]'
|
||||
# are inside scope name strings, so REPLACE is safe here.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE app_passwords
|
||||
SET scopes = REPLACE(scopes, ':', '.')
|
||||
WHERE scopes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Update requested_scopes in login_flow_sessions
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE login_flow_sessions
|
||||
SET requested_scopes = REPLACE(requested_scopes, ':', '.')
|
||||
WHERE requested_scopes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert dot separator back to colon in stored scope strings."""
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE app_passwords
|
||||
SET scopes = REPLACE(scopes, '.', ':')
|
||||
WHERE scopes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE login_flow_sessions
|
||||
SET requested_scopes = REPLACE(requested_scopes, '.', ':')
|
||||
WHERE requested_scopes IS NOT NULL
|
||||
"""
|
||||
)
|
||||
@@ -462,19 +462,19 @@ async def load_oauth_client_credentials(
|
||||
# These must stay in sync — any scope a tool uses via @require_scopes must be listed here.
|
||||
dcr_scopes = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write calendar:read calendar:write todo:read todo:write "
|
||||
"contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write "
|
||||
"tables:read tables:write files:read files:write sharing:read sharing:write "
|
||||
"news:read news:write collectives:read collectives:write"
|
||||
"notes.read notes.write calendar.read calendar.write todo.read todo.write "
|
||||
"contacts.read contacts.write cookbook.read cookbook.write deck.read deck.write "
|
||||
"tables.read tables.write files.read files.write sharing.read sharing.write "
|
||||
"news.read news.write collectives.read collectives.write"
|
||||
)
|
||||
|
||||
# Add conditional scopes based on server configuration
|
||||
dcr_settings = get_settings()
|
||||
|
||||
# semantic:read gates MCP-server-level semantic search tools
|
||||
# semantic.read gates MCP-server-level semantic search tools
|
||||
if dcr_settings.vector_sync_enabled:
|
||||
dcr_scopes = f"{dcr_scopes} semantic:read"
|
||||
logger.info("✓ semantic:read scope enabled for semantic search tools")
|
||||
dcr_scopes = f"{dcr_scopes} semantic.read"
|
||||
logger.info("✓ semantic.read scope enabled for semantic search tools")
|
||||
|
||||
# offline_access enables refresh tokens for background operations
|
||||
enable_offline_access = dcr_settings.enable_offline_access
|
||||
|
||||
@@ -86,7 +86,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Request only basic OIDC scopes for browser session
|
||||
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
|
||||
# Note: Nextcloud app scopes (notes.read, etc.) are for MCP client access tokens,
|
||||
# not for the MCP server's own browser authentication
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def require_scopes(*required_scopes: str):
|
||||
users who lack the necessary scopes.
|
||||
|
||||
Args:
|
||||
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
||||
*required_scopes: Variable number of scope strings required (e.g., "notes.read", "notes.write")
|
||||
|
||||
Returns:
|
||||
Decorated function that checks scopes before execution
|
||||
@@ -82,15 +82,15 @@ def require_scopes(*required_scopes: str):
|
||||
Example:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_scopes("notes.read")
|
||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||
# This tool requires the notes:read scope
|
||||
# This tool requires the notes.read scope
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
@require_scopes("notes.write")
|
||||
async def nc_notes_create_note(ctx: Context, ...):
|
||||
# This tool requires the notes:write scope
|
||||
# This tool requires the notes.write scope
|
||||
...
|
||||
```
|
||||
|
||||
@@ -203,12 +203,12 @@ def require_scopes(*required_scopes: str):
|
||||
if any(
|
||||
s.startswith(prefix)
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
"notes.",
|
||||
"calendar.",
|
||||
"contacts.",
|
||||
"files.",
|
||||
"tables.",
|
||||
"deck.",
|
||||
]
|
||||
)
|
||||
]
|
||||
@@ -223,12 +223,12 @@ def require_scopes(*required_scopes: str):
|
||||
s.startswith(prefix)
|
||||
for s in token_scopes
|
||||
for prefix in [
|
||||
"notes:",
|
||||
"calendar:",
|
||||
"contacts:",
|
||||
"files:",
|
||||
"tables:",
|
||||
"deck:",
|
||||
"notes.",
|
||||
"calendar.",
|
||||
"contacts.",
|
||||
"files.",
|
||||
"tables.",
|
||||
"deck.",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -305,7 +305,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
||||
Example:
|
||||
```python
|
||||
async def my_tool(ctx: Context):
|
||||
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
||||
has_scopes, missing = check_scopes(ctx, "notes.read", "notes.write")
|
||||
if not has_scopes:
|
||||
# Handle missing scopes
|
||||
...
|
||||
@@ -335,11 +335,11 @@ def get_required_scopes(func: Callable) -> list[str]:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
@require_scopes("notes.read", "notes.write")
|
||||
async def my_tool():
|
||||
pass
|
||||
|
||||
scopes = get_required_scopes(my_tool) # ["notes:read", "notes:write"]
|
||||
scopes = get_required_scopes(my_tool) # ["notes.read", "notes.write"]
|
||||
```
|
||||
"""
|
||||
return getattr(func, "_required_scopes", [])
|
||||
@@ -385,14 +385,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
||||
|
||||
Example:
|
||||
```python
|
||||
@require_scopes("notes:write")
|
||||
@require_scopes("notes.write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
user_scopes = {"notes:read", "notes:write"}
|
||||
user_scopes = {"notes.read", "notes.write"}
|
||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||
|
||||
limited_user_scopes = {"notes:read"}
|
||||
limited_user_scopes = {"notes.read"}
|
||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||
```
|
||||
"""
|
||||
@@ -431,17 +431,17 @@ def discover_all_scopes(mcp) -> list[str]:
|
||||
mcp = FastMCP("My Server")
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_scopes("notes.read")
|
||||
async def get_notes():
|
||||
pass
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
@require_scopes("notes.write")
|
||||
async def create_note():
|
||||
pass
|
||||
|
||||
scopes = discover_all_scopes(mcp)
|
||||
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
||||
# Returns: ["notes.read", "notes.write", "openid", "profile", "email"]
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 \\
|
||||
|
||||
@@ -54,27 +54,27 @@ class UpdateScopesResponse(BaseResponse):
|
||||
# All supported application-level scopes (frozenset for O(1) membership tests)
|
||||
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"todo:read",
|
||||
"todo:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"news:read",
|
||||
"news:write",
|
||||
"collectives:read",
|
||||
"collectives:write",
|
||||
"notes.read",
|
||||
"notes.write",
|
||||
"calendar.read",
|
||||
"calendar.write",
|
||||
"todo.read",
|
||||
"todo.write",
|
||||
"contacts.read",
|
||||
"contacts.write",
|
||||
"files.read",
|
||||
"files.write",
|
||||
"tables.read",
|
||||
"tables.write",
|
||||
"deck.read",
|
||||
"deck.write",
|
||||
"cookbook.read",
|
||||
"cookbook.write",
|
||||
"sharing.read",
|
||||
"sharing.write",
|
||||
"news.read",
|
||||
"news.write",
|
||||
"collectives.read",
|
||||
"collectives.write",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -410,7 +410,7 @@ def instrument_tool(func):
|
||||
|
||||
Usage:
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:write")
|
||||
@require_scopes("notes.write")
|
||||
@instrument_tool
|
||||
async def nc_notes_create_note(...):
|
||||
...
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,10 +46,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Scopes required for vector sync operations
|
||||
VECTOR_SYNC_SCOPES = [
|
||||
"notes:read",
|
||||
"files:read",
|
||||
"deck:read",
|
||||
# "news:read", # News app may not be installed
|
||||
"notes.read",
|
||||
"files.read",
|
||||
"deck.read",
|
||||
# "news.read", # News app may not be installed
|
||||
]
|
||||
|
||||
|
||||
|
||||
+33
-33
@@ -29,43 +29,43 @@ logger = logging.getLogger(__name__)
|
||||
# Default scopes for OAuth testing - all app-specific read/write scopes
|
||||
DEFAULT_FULL_SCOPES = (
|
||||
"openid profile email "
|
||||
"notes:read notes:write "
|
||||
"calendar:read calendar:write "
|
||||
"todo:read todo:write "
|
||||
"contacts:read contacts:write "
|
||||
"cookbook:read cookbook:write "
|
||||
"deck:read deck:write "
|
||||
"tables:read tables:write "
|
||||
"files:read files:write "
|
||||
"sharing:read sharing:write"
|
||||
"notes.read notes.write "
|
||||
"calendar.read calendar.write "
|
||||
"todo.read todo.write "
|
||||
"contacts.read contacts.write "
|
||||
"cookbook.read cookbook.write "
|
||||
"deck.read deck.write "
|
||||
"tables.read tables.write "
|
||||
"files.read files.write "
|
||||
"sharing.read sharing.write"
|
||||
)
|
||||
|
||||
# Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion
|
||||
DEFAULT_READ_SCOPES = (
|
||||
"openid profile email "
|
||||
"notes:read "
|
||||
"calendar:read "
|
||||
"todo:read "
|
||||
"contacts:read "
|
||||
"cookbook:read "
|
||||
"deck:read "
|
||||
"tables:read "
|
||||
"files:read "
|
||||
"sharing:read"
|
||||
"notes.read "
|
||||
"calendar.read "
|
||||
"todo.read "
|
||||
"contacts.read "
|
||||
"cookbook.read "
|
||||
"deck.read "
|
||||
"tables.read "
|
||||
"files.read "
|
||||
"sharing.read"
|
||||
)
|
||||
|
||||
# Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion
|
||||
DEFAULT_WRITE_SCOPES = (
|
||||
"openid profile email "
|
||||
"notes:write "
|
||||
"calendar:write "
|
||||
"todo:write "
|
||||
"contacts:write "
|
||||
"cookbook:write "
|
||||
"deck:write "
|
||||
"tables:write "
|
||||
"files:write "
|
||||
"sharing:write"
|
||||
"notes.write "
|
||||
"calendar.write "
|
||||
"todo.write "
|
||||
"contacts.write "
|
||||
"cookbook.write "
|
||||
"deck.write "
|
||||
"tables.write "
|
||||
"files.write "
|
||||
"sharing.write"
|
||||
)
|
||||
|
||||
|
||||
@@ -545,7 +545,7 @@ async def nc_mcp_oauth_client_no_custom_scopes(
|
||||
Connects to the OAuth-enabled MCP server on port 8001.
|
||||
|
||||
This client has only OIDC default scopes (openid, profile, email) without
|
||||
application-specific scopes (notes:read, notes:write, etc.).
|
||||
application-specific scopes (notes.read, notes.write, etc.).
|
||||
|
||||
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||
|
||||
@@ -1671,7 +1671,7 @@ async def no_custom_scopes_oauth_client_credentials(
|
||||
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
||||
|
||||
Tests the security behavior when a user grants only the default OIDC scopes
|
||||
(openid, profile, email) but declines custom application scopes (notes:read, notes:write, etc.).
|
||||
(openid, profile, email) but declines custom application scopes (notes.read, notes.write, etc.).
|
||||
|
||||
The client is automatically deleted from Nextcloud after the test session completes.
|
||||
|
||||
@@ -1808,7 +1808,7 @@ async def playwright_oauth_token(
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||
f"scope=openid%20profile%20email%20notes.read%20notes.write%20calendar.read%20calendar.write%20contacts.read%20contacts.write%20cookbook.read%20cookbook.write%20deck.read%20deck.write%20tables.read%20tables.write%20files.read%20files.write%20sharing.read%20sharing.write"
|
||||
)
|
||||
|
||||
# Add resource parameter (RFC 8707) if available
|
||||
@@ -2060,7 +2060,7 @@ async def _get_oauth_token_with_scopes(
|
||||
browser: Playwright browser instance
|
||||
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
||||
oauth_callback_server: OAuth callback server fixture
|
||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes.read")
|
||||
resource: Optional resource parameter (RFC 8707) for token audience
|
||||
mcp_server_base_url: Base URL of the MCP server for resource metadata discovery
|
||||
|
||||
@@ -2521,7 +2521,7 @@ async def _get_oauth_token_for_user(
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"resource={quote(mcp_server_resource, safe='')}&" # Resource URI from PRM
|
||||
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||
f"scope=openid%20profile%20email%20notes.read%20notes.write%20calendar.read%20calendar.write%20contacts.read%20contacts.write%20cookbook.read%20cookbook.write%20deck.read%20deck.write%20tables.read%20tables.write%20files.read%20files.write%20sharing.read%20sharing.write"
|
||||
)
|
||||
|
||||
logger.info(f"Performing browser OAuth flow for {username}...")
|
||||
@@ -3038,7 +3038,7 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
||||
"--resource_url",
|
||||
mcp_server_public_url,
|
||||
"--allowed_scopes",
|
||||
"openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write",
|
||||
"openid profile email offline_access notes.read notes.write calendar.read calendar.write contacts.read contacts.write cookbook.read cookbook.write deck.read deck.write tables.read tables.write files.read files.write",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
|
||||
@@ -37,7 +37,7 @@ async def get_oauth_token_with_client(
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
scopes: str = "openid profile email notes.read notes.write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
@@ -187,7 +187,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "openid profile email notes:read",
|
||||
"scope": "openid profile email notes.read",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
@@ -209,7 +209,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Lifecycle Test Client 2",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read",
|
||||
scopes="openid profile email notes.read",
|
||||
token_type="Bearer",
|
||||
)
|
||||
|
||||
@@ -235,7 +235,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
scopes="openid profile email notes:read",
|
||||
scopes="openid profile email notes.read",
|
||||
)
|
||||
|
||||
assert access_token, "Failed to obtain access token"
|
||||
|
||||
@@ -93,7 +93,7 @@ async def get_oauth_token_with_client(
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
scopes: str = "openid profile email notes.read notes.write",
|
||||
) -> str:
|
||||
"""
|
||||
Helper to obtain OAuth access token using existing client credentials.
|
||||
@@ -241,7 +241,7 @@ async def test_dcr_respects_jwt_token_type(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - JWT Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
scopes="openid profile email notes.read notes.write",
|
||||
token_type="jwt",
|
||||
)
|
||||
|
||||
@@ -276,8 +276,8 @@ async def test_dcr_respects_jwt_token_type(
|
||||
# Verify scope claim exists (critical for MCP tool filtering)
|
||||
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||
scopes = payload["scope"].split()
|
||||
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
||||
assert "notes.read" in scopes, "JWT scope claim missing notes.read"
|
||||
assert "notes.write" in scopes, "JWT scope claim missing notes.write"
|
||||
|
||||
logger.info(
|
||||
f"✅ DCR with token_type=jwt works correctly! "
|
||||
@@ -325,7 +325,7 @@ async def test_dcr_respects_bearer_token_type(
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="DCR Test - Opaque Token Type",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
scopes="openid profile email notes.read notes.write",
|
||||
token_type="opaque",
|
||||
)
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ async def test_prm_endpoint():
|
||||
|
||||
prm_data = response.json()
|
||||
assert prm_data["resource"] == "http://localhost:8004/mcp"
|
||||
assert "notes:read" in prm_data["scopes_supported"]
|
||||
assert "notes:write" in prm_data["scopes_supported"]
|
||||
assert "notes.read" in prm_data["scopes_supported"]
|
||||
assert "notes.write" in prm_data["scopes_supported"]
|
||||
assert "http://localhost:8004" in prm_data["authorization_servers"]
|
||||
assert "header" in prm_data["bearer_methods_supported"]
|
||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||
@@ -67,7 +67,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "notes:read" scope
|
||||
# Connect with token that has only "notes.read" scope
|
||||
result = await nc_mcp_login_flow_client_read_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
@@ -76,13 +76,13 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
||||
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify read tools are present (only for apps with :read scopes)
|
||||
# Read-only token has: notes:read, calendar:read, contacts:read,
|
||||
# cookbook:read, deck:read, tables:read, files:read, sharing:read
|
||||
# Read-only token has: notes.read, calendar.read, contacts.read,
|
||||
# cookbook.read, deck.read, tables.read, files.read, sharing.read
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
"nc_notes_get_note", # notes.read
|
||||
"nc_notes_search_notes", # notes.read
|
||||
"nc_calendar_list_calendars", # calendar.read
|
||||
"nc_calendar_get_event", # calendar.read
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
@@ -90,12 +90,12 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
||||
|
||||
# Verify write tools are NOT present (filtered out)
|
||||
write_tools_should_be_filtered = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
"nc_notes_create_note", # notes.write
|
||||
"nc_notes_update_note", # notes.write
|
||||
"nc_notes_delete_note", # notes.write
|
||||
"nc_calendar_create_event", # calendar.write
|
||||
"nc_calendar_update_event", # calendar.write
|
||||
"nc_calendar_delete_event", # calendar.write
|
||||
]
|
||||
|
||||
for tool in write_tools_should_be_filtered:
|
||||
@@ -116,7 +116,7 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has only "notes:write" scope
|
||||
# Connect with token that has only "notes.write" scope
|
||||
result = await nc_mcp_login_flow_client_write_only.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
@@ -125,15 +125,15 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
|
||||
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||
|
||||
# Verify write tools are present
|
||||
# Write-only token has: notes:write, calendar:write, contacts:write,
|
||||
# cookbook:write, deck:write, tables:write, files:write, sharing:write
|
||||
# Write-only token has: notes.write, calendar.write, contacts.write,
|
||||
# cookbook.write, deck.write, tables.write, files.write, sharing.write
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_notes_update_note", # notes:write
|
||||
"nc_notes_delete_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_calendar_update_event", # calendar:write
|
||||
"nc_calendar_delete_event", # calendar:write
|
||||
"nc_notes_create_note", # notes.write
|
||||
"nc_notes_update_note", # notes.write
|
||||
"nc_notes_delete_note", # notes.write
|
||||
"nc_calendar_create_event", # calendar.write
|
||||
"nc_calendar_update_event", # calendar.write
|
||||
"nc_calendar_delete_event", # calendar.write
|
||||
]
|
||||
|
||||
for tool in expected_write_tools:
|
||||
@@ -141,10 +141,10 @@ async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_writ
|
||||
|
||||
# Verify read-only tools are NOT present (write-only scope)
|
||||
read_tools_should_be_filtered = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_calendar_get_event", # calendar:read
|
||||
"nc_notes_get_note", # notes.read
|
||||
"nc_notes_search_notes", # notes.read
|
||||
"nc_calendar_list_calendars", # calendar.read
|
||||
"nc_calendar_get_event", # calendar.read
|
||||
]
|
||||
|
||||
for tool in read_tools_should_be_filtered:
|
||||
@@ -165,7 +165,7 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Connect with token that has both "notes:read" and "notes:write" scopes
|
||||
# Connect with token that has both "notes.read" and "notes.write" scopes
|
||||
result = await nc_mcp_login_flow_client_full_access.list_tools()
|
||||
assert result is not None
|
||||
assert len(result.tools) > 0
|
||||
@@ -177,14 +177,14 @@ async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_a
|
||||
# Verify both read and write tools are present
|
||||
# Full access has all *read and *write scopes
|
||||
expected_read_tools = [
|
||||
"nc_notes_get_note", # notes:read
|
||||
"nc_notes_search_notes", # notes:read
|
||||
"nc_calendar_list_calendars", # calendar:read
|
||||
"nc_notes_get_note", # notes.read
|
||||
"nc_notes_search_notes", # notes.read
|
||||
"nc_calendar_list_calendars", # calendar.read
|
||||
]
|
||||
|
||||
expected_write_tools = [
|
||||
"nc_notes_create_note", # notes:write
|
||||
"nc_calendar_create_event", # calendar:write
|
||||
"nc_notes_create_note", # notes.write
|
||||
"nc_calendar_create_event", # calendar.write
|
||||
]
|
||||
|
||||
for tool in expected_read_tools:
|
||||
@@ -217,17 +217,17 @@ async def test_scope_helper_functions():
|
||||
pass
|
||||
|
||||
# Add scope metadata
|
||||
mock_read_tool._required_scopes = ["notes:read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
|
||||
mock_read_tool._required_scopes = ["notes.read"] # type: ignore
|
||||
mock_write_tool._required_scopes = ["notes.write"] # type: ignore
|
||||
|
||||
# Test get_required_scopes
|
||||
assert get_required_scopes(mock_read_tool) == ["notes:read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["notes:write"]
|
||||
assert get_required_scopes(mock_read_tool) == ["notes.read"]
|
||||
assert get_required_scopes(mock_write_tool) == ["notes.write"]
|
||||
assert get_required_scopes(mock_no_scope_tool) == []
|
||||
|
||||
# Test has_required_scopes
|
||||
read_only_scopes = {"notes:read"}
|
||||
full_scopes = {"notes:read", "notes:write"}
|
||||
read_only_scopes = {"notes.read"}
|
||||
full_scopes = {"notes.read", "notes.write"}
|
||||
no_scopes = set()
|
||||
|
||||
# User with only read scope
|
||||
@@ -251,13 +251,13 @@ async def test_scope_decorator_stores_metadata():
|
||||
"""Test that @require_scopes decorator properly stores metadata."""
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
|
||||
@require_scopes("notes:read", "notes:write")
|
||||
@require_scopes("notes.read", "notes.write")
|
||||
async def test_function():
|
||||
pass
|
||||
|
||||
# Check that metadata was stored
|
||||
assert hasattr(test_function, "_required_scopes")
|
||||
assert test_function._required_scopes == ["notes:read", "notes:write"]
|
||||
assert test_function._required_scopes == ["notes.read", "notes.write"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -308,28 +308,28 @@ async def test_scope_classification():
|
||||
from scripts.add_scope_decorators_simple import classify_function
|
||||
|
||||
# Test read operations
|
||||
assert classify_function("nc_notes_get_note") == "notes:read"
|
||||
assert classify_function("nc_notes_search_notes") == "notes:read"
|
||||
assert classify_function("nc_calendar_list_events") == "calendar:read"
|
||||
assert classify_function("nc_webdav_read_file") == "files:read"
|
||||
assert classify_function("nc_calendar_find_availability") == "calendar:read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
|
||||
assert classify_function("nc_notes_get_note") == "notes.read"
|
||||
assert classify_function("nc_notes_search_notes") == "notes.read"
|
||||
assert classify_function("nc_calendar_list_events") == "calendar.read"
|
||||
assert classify_function("nc_webdav_read_file") == "files.read"
|
||||
assert classify_function("nc_calendar_find_availability") == "calendar.read"
|
||||
assert classify_function("nc_calendar_get_upcoming_events") == "notes.read"
|
||||
|
||||
# Test write operations
|
||||
assert classify_function("nc_notes_create_note") == "notes:write"
|
||||
assert classify_function("nc_notes_update_note") == "notes:write"
|
||||
assert classify_function("nc_notes_delete_note") == "notes:write"
|
||||
assert classify_function("nc_notes_append_content") == "notes:write"
|
||||
assert classify_function("nc_calendar_create_event") == "calendar:write"
|
||||
assert classify_function("nc_calendar_update_event") == "notes:write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "notes:write"
|
||||
assert classify_function("nc_webdav_write_file") == "files:write"
|
||||
assert classify_function("nc_webdav_move_resource") == "notes:write"
|
||||
assert classify_function("nc_contacts_create_contact") == "notes:write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
|
||||
assert classify_function("nc_tables_insert_row") == "notes:write"
|
||||
assert classify_function("deck_archive_card") == "notes:write"
|
||||
assert classify_function("deck_assign_label_to_card") == "notes:write"
|
||||
assert classify_function("nc_notes_create_note") == "notes.write"
|
||||
assert classify_function("nc_notes_update_note") == "notes.write"
|
||||
assert classify_function("nc_notes_delete_note") == "notes.write"
|
||||
assert classify_function("nc_notes_append_content") == "notes.write"
|
||||
assert classify_function("nc_calendar_create_event") == "calendar.write"
|
||||
assert classify_function("nc_calendar_update_event") == "notes.write"
|
||||
assert classify_function("nc_calendar_manage_calendar") == "notes.write"
|
||||
assert classify_function("nc_webdav_write_file") == "files.write"
|
||||
assert classify_function("nc_webdav_move_resource") == "notes.write"
|
||||
assert classify_function("nc_contacts_create_contact") == "notes.write"
|
||||
assert classify_function("nc_cookbook_import_recipe") == "notes.write"
|
||||
assert classify_function("nc_tables_insert_row") == "notes.write"
|
||||
assert classify_function("deck_archive_card") == "notes.write"
|
||||
assert classify_function("deck_assign_label_to_card") == "notes.write"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Script no longer exists - decorators are already in place")
|
||||
|
||||
+9
-9
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -38,14 +38,14 @@ async def test_store_app_password_with_scopes(temp_storage):
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
scopes=["notes:read", "notes:write"],
|
||||
scopes=["notes.read", "notes.write"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||
assert data is not None
|
||||
assert data["app_password"] == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
||||
assert data["scopes"] == ["notes:read", "notes:write"]
|
||||
assert data["scopes"] == ["notes.read", "notes.write"]
|
||||
assert data["username"] == "alice_nc"
|
||||
assert data["created_at"] is not None
|
||||
assert data["updated_at"] is not None
|
||||
@@ -70,18 +70,18 @@ async def test_store_app_password_with_scopes_replaces(temp_storage):
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||
scopes=["notes:read"],
|
||||
scopes=["notes.read"],
|
||||
)
|
||||
await temp_storage.store_app_password_with_scopes(
|
||||
user_id="alice",
|
||||
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||
scopes=["notes:read", "calendar:read"],
|
||||
scopes=["notes.read", "calendar.read"],
|
||||
username="alice_nc",
|
||||
)
|
||||
|
||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||
assert data["app_password"] == "xxxxx-yyyyy-zzzzz-aaaaa-bbbbb"
|
||||
assert data["scopes"] == ["notes:read", "calendar:read"]
|
||||
assert data["scopes"] == ["notes.read", "calendar.read"]
|
||||
|
||||
|
||||
async def test_get_app_password_with_scopes_nonexistent(temp_storage):
|
||||
@@ -99,14 +99,14 @@ async def test_store_and_get_login_flow_session(temp_storage):
|
||||
user_id="alice",
|
||||
poll_token="secret-poll-token",
|
||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
||||
requested_scopes=["notes:read", "notes:write"],
|
||||
requested_scopes=["notes.read", "notes.write"],
|
||||
)
|
||||
|
||||
session = await temp_storage.get_login_flow_session("alice")
|
||||
assert session is not None
|
||||
assert session["poll_token"] == "secret-poll-token"
|
||||
assert session["poll_endpoint"] == "https://cloud.example.com/login/v2/poll"
|
||||
assert session["requested_scopes"] == ["notes:read", "notes:write"]
|
||||
assert session["requested_scopes"] == ["notes.read", "notes.write"]
|
||||
assert session["created_at"] is not None
|
||||
assert session["expires_at"] is not None
|
||||
|
||||
@@ -187,11 +187,11 @@ async def test_delete_expired_login_flow_sessions(temp_storage):
|
||||
|
||||
def test_all_supported_scopes():
|
||||
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
|
||||
assert "notes:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "notes:write" in ALL_SUPPORTED_SCOPES
|
||||
assert "calendar:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "files:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "deck:read" in ALL_SUPPORTED_SCOPES
|
||||
assert "notes.read" in ALL_SUPPORTED_SCOPES
|
||||
assert "notes.write" in ALL_SUPPORTED_SCOPES
|
||||
assert "calendar.read" in ALL_SUPPORTED_SCOPES
|
||||
assert "files.read" in ALL_SUPPORTED_SCOPES
|
||||
assert "deck.read" in ALL_SUPPORTED_SCOPES
|
||||
# Scopes should be in pairs (read/write)
|
||||
read_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":read")]
|
||||
write_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":write")]
|
||||
|
||||
@@ -29,7 +29,7 @@ async def test_get_stored_scopes_with_scopes():
|
||||
mock_storage = AsyncMock()
|
||||
mock_storage.get_app_password_with_scopes.return_value = {
|
||||
"app_password": "xxxxx",
|
||||
"scopes": ["notes:read", "calendar:read"],
|
||||
"scopes": ["notes.read", "calendar.read"],
|
||||
"username": "alice",
|
||||
"created_at": 1000,
|
||||
"updated_at": 1000,
|
||||
@@ -41,7 +41,7 @@ async def test_get_stored_scopes_with_scopes():
|
||||
):
|
||||
result = await _get_stored_scopes("alice")
|
||||
|
||||
assert result == ["notes:read", "calendar:read"]
|
||||
assert result == ["notes.read", "calendar.read"]
|
||||
|
||||
|
||||
async def test_get_stored_scopes_null_scopes():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user