refactor: change OAuth scope separator from colon to dot for IDP compatibility
Many identity providers (AWS Cognito, Okta, Azure AD) reject or mishandle colons in OAuth scope names. This migrates all custom scopes from `resource:action` to `resource.action` format (e.g., `notes:read` → `notes.read`), which is universally accepted and aligns with industry conventions (Microsoft, Google). Includes Alembic migration 004 for stored scope strings and ADR-024 documenting the rationale and RFC references. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
899b9c7191
commit
29fd0486c9
@@ -373,7 +373,7 @@ MCP sampling allows servers to request LLM completions from their clients. This
|
|||||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
async def nc_notes_semantic_search_answer(
|
async def nc_notes_semantic_search_answer(
|
||||||
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
|
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
|
||||||
) -> SamplingSearchResponse:
|
) -> SamplingSearchResponse:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ CLIENT_ID="astrolabeMcpClientOAuth00000000000"
|
|||||||
REDIRECT_URI="${NC_EXTERNAL_URL}/apps/astrolabe/oauth/callback"
|
REDIRECT_URI="${NC_EXTERNAL_URL}/apps/astrolabe/oauth/callback"
|
||||||
|
|
||||||
# All scopes the MCP server supports (must match DCR scopes in app.py)
|
# 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
|
# Create OAuth client
|
||||||
CLIENT_JSON=$(php occ oidc:create "Astrolabe" \
|
CLIENT_JSON=$(php occ oidc:create "Astrolabe" \
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ auth:
|
|||||||
clientId: ""
|
clientId: ""
|
||||||
clientSecret: ""
|
clientSecret: ""
|
||||||
# OAuth scopes to request (space-separated)
|
# 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
|
# Use existing secret for multi-user basic auth credentials
|
||||||
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
|
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
|
||||||
# Secret should contain keys specified in the *Key fields below
|
# 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)
|
# Pre-registered OAuth client secret (optional, ignored if existingSecret is set)
|
||||||
clientSecret: ""
|
clientSecret: ""
|
||||||
# OAuth scopes to request (space-separated)
|
# 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
|
# Use existing secret for OAuth client credentials
|
||||||
# If set, clientId and clientSecret above are ignored
|
# If set, clientId and clientSecret above are ignored
|
||||||
# Secret must contain keys specified in clientIdKey and clientSecretKey
|
# Secret must contain keys specified in clientIdKey and clientSecretKey
|
||||||
|
|||||||
+1
-1
@@ -235,7 +235,7 @@ services:
|
|||||||
- ENABLE_DCR=true
|
- ENABLE_DCR=true
|
||||||
|
|
||||||
# OAuth scopes (optional - uses defaults if not specified)
|
# 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!
|
# NO admin credentials - using external IdP OAuth only!
|
||||||
volumes:
|
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
|
- ✅ Generated **on-demand** during tool execution
|
||||||
- ✅ **Ephemeral** - used only for current operation
|
- ✅ **Ephemeral** - used only for current operation
|
||||||
- ✅ **NOT stored** - discarded after use
|
- ✅ **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)
|
- ✅ **Short-lived** - expires quickly (e.g., 5 minutes)
|
||||||
|
|
||||||
**Background Jobs (Offline Operations)**:
|
**Background Jobs (Offline Operations)**:
|
||||||
@@ -202,7 +202,7 @@ Tools use the same pattern regardless of token acquisition mode:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@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
|
@require_provisioning
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
"""Search notes by title or content."""
|
"""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
|
## 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:
|
This means:
|
||||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
- 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
|
### Key Features
|
||||||
|
|
||||||
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
- ✅ **JWT Token Support** - RFC 9068 compliant access tokens with RS256 signatures
|
||||||
- ✅ **Custom Scopes** - `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
|
- ✅ **Dynamic Tool Filtering** - Tools filtered based on user's token scopes
|
||||||
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
- ✅ **Scope Challenges** - RFC-compliant `WWW-Authenticate` headers for insufficient scopes
|
||||||
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
- ✅ **Protected Resource Metadata** - RFC 9728 endpoint for scope discovery
|
||||||
@@ -38,8 +38,8 @@ The Nextcloud MCP Server supports OAuth authentication with both **JWT** (RFC 90
|
|||||||
|
|
||||||
| Scope | Description | Tool Count |
|
| Scope | Description | Tool Count |
|
||||||
|-------|-------------|------------|
|
|-------|-------------|------------|
|
||||||
| `mcp:notes:read` | Read-only access to Nextcloud data | 36 tools |
|
| `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.write` | Write access to create/modify/delete data | 54 tools |
|
||||||
|
|
||||||
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
All MCP tools (90 total) require at least one of these scopes. Standard OIDC scopes (`openid`, `profile`, `email`) are also supported.
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ The Nextcloud OIDC app supports two token formats, configured per-client:
|
|||||||
"aud": "client_id",
|
"aud": "client_id",
|
||||||
"exp": 1234567890,
|
"exp": 1234567890,
|
||||||
"iat": 1234567890,
|
"iat": 1234567890,
|
||||||
"scope": "openid profile email mcp:notes:read mcp:notes:write",
|
"scope": "openid profile email mcp:notes.read mcp:notes.write",
|
||||||
"client_id": "...",
|
"client_id": "...",
|
||||||
"jti": "..."
|
"jti": "..."
|
||||||
}
|
}
|
||||||
@@ -116,8 +116,8 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
|||||||
|
|
||||||
| Scope | Operations | Examples |
|
| Scope | Operations | Examples |
|
||||||
|-------|------------|----------|
|
|-------|------------|----------|
|
||||||
| `mcp:notes:read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
| `mcp:notes.read` | Read-only access | Get notes, search files, list calendars, read contacts |
|
||||||
| `mcp:notes:write` | Write operations | Create notes, update events, delete files, modify contacts |
|
| `mcp:notes.write` | Write operations | Create notes, update events, delete files, modify contacts |
|
||||||
|
|
||||||
### Standard OIDC Scopes
|
### Standard OIDC Scopes
|
||||||
|
|
||||||
@@ -131,12 +131,12 @@ The MCP server uses **coarse-grained scopes** for simplicity:
|
|||||||
|
|
||||||
**Full Access:**
|
**Full Access:**
|
||||||
```
|
```
|
||||||
openid profile email mcp:notes:read mcp:notes:write
|
openid profile email mcp:notes.read mcp:notes.write
|
||||||
```
|
```
|
||||||
|
|
||||||
**Read-Only:**
|
**Read-Only:**
|
||||||
```
|
```
|
||||||
openid profile email mcp:notes:read
|
openid profile email mcp:notes.read
|
||||||
```
|
```
|
||||||
|
|
||||||
**No Custom Scopes (OIDC only):**
|
**No Custom Scopes (OIDC only):**
|
||||||
@@ -150,32 +150,32 @@ All 90 MCP tools are decorated with scope requirements:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("mcp:notes:read")
|
@require_scopes("mcp:notes.read")
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context):
|
async def nc_notes_get_note(note_id: int, ctx: Context):
|
||||||
"""Get a note by ID (requires mcp:notes:read scope)"""
|
"""Get a note by ID (requires mcp:notes.read scope)"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("mcp:notes:write")
|
@require_scopes("mcp:notes.write")
|
||||||
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
||||||
"""Create a note (requires mcp:notes:write scope)"""
|
"""Create a note (requires mcp:notes.write scope)"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes:read")`
|
- ✅ 36 read tools decorated with `@require_scopes("mcp:notes.read")`
|
||||||
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes:write")`
|
- ✅ 54 write tools decorated with `@require_scopes("mcp:notes.write")`
|
||||||
- ✅ 90/90 tools covered (100%)
|
- ✅ 90/90 tools covered (100%)
|
||||||
|
|
||||||
### Dynamic Tool Filtering
|
### Dynamic Tool Filtering
|
||||||
|
|
||||||
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use. This applies to **both JWT and Bearer (opaque) tokens** in OAuth mode:
|
||||||
|
|
||||||
**Token with `mcp:notes:read` only:**
|
**Token with `mcp:notes.read` only:**
|
||||||
- `list_tools()` returns 36 read-only tools
|
- `list_tools()` returns 36 read-only tools
|
||||||
- Write tools are hidden from the tool list
|
- Write tools are hidden from the tool list
|
||||||
|
|
||||||
**Token with `mcp:notes:write` only:**
|
**Token with `mcp:notes.write` only:**
|
||||||
- `list_tools()` returns 54 write-only tools
|
- `list_tools()` returns 54 write-only tools
|
||||||
- Read tools are hidden from the tool list
|
- Read tools are hidden from the tool list
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ The MCP server implements **dynamic tool filtering** - users only see tools they
|
|||||||
- `list_tools()` returns all 90 tools
|
- `list_tools()` returns all 90 tools
|
||||||
|
|
||||||
**Token with no custom scopes:**
|
**Token with no custom scopes:**
|
||||||
- `list_tools()` returns 0 tools (all require `mcp:notes:read` or `mcp:notes:write`)
|
- `list_tools()` returns 0 tools (all require `mcp:notes.read` or `mcp:notes.write`)
|
||||||
|
|
||||||
**BasicAuth mode:**
|
**BasicAuth mode:**
|
||||||
- `list_tools()` returns all 90 tools (no filtering)
|
- `list_tools()` returns all 90 tools (no filtering)
|
||||||
@@ -197,7 +197,7 @@ When a tool is called without required scopes, the server returns a `403 Forbidd
|
|||||||
```http
|
```http
|
||||||
HTTP/1.1 403 Forbidden
|
HTTP/1.1 403 Forbidden
|
||||||
WWW-Authenticate: Bearer error="insufficient_scope",
|
WWW-Authenticate: Bearer error="insufficient_scope",
|
||||||
scope="mcp:notes:write",
|
scope="mcp:notes.write",
|
||||||
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
resource_metadata="http://server/.well-known/oauth-protected-resource/mcp"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ The server implements RFC 9728's Protected Resource Metadata endpoint:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"resource": "http://localhost:8001/mcp",
|
"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"],
|
"authorization_servers": ["http://localhost:8080"],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"]
|
"resource_signing_alg_values_supported": ["RS256"]
|
||||||
@@ -250,7 +250,7 @@ mcp-oauth:
|
|||||||
- NEXTCLOUD_HOST=http://app:80
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
|
||||||
volumes:
|
volumes:
|
||||||
- oauth-client-storage:/app/.oauth # Persist DCR credentials
|
- 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_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_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
|
||||||
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
|
||||||
| `NEXTCLOUD_OIDC_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"` |
|
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
|
||||||
|
|
||||||
### Dynamic Client Registration (DCR)
|
### Dynamic Client Registration (DCR)
|
||||||
@@ -320,7 +320,7 @@ DCR automatically configures the client based on environment variables:
|
|||||||
# Minimal DCR configuration (no credentials needed!)
|
# Minimal DCR configuration (no credentials needed!)
|
||||||
export NEXTCLOUD_HOST=http://localhost:8080
|
export NEXTCLOUD_HOST=http://localhost:8080
|
||||||
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
export NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
export NEXTCLOUD_OIDC_SCOPES="openid profile email 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
|
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
|
```bash
|
||||||
docker compose exec app php occ oidc:create \
|
docker compose exec app php occ oidc:create \
|
||||||
--token_type=jwt \
|
--token_type=jwt \
|
||||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
|
||||||
"Nextcloud MCP Server" \
|
"Nextcloud MCP Server" \
|
||||||
"http://localhost:8000/oauth/callback"
|
"http://localhost:8000/oauth/callback"
|
||||||
```
|
```
|
||||||
@@ -373,7 +373,7 @@ docker compose exec app php occ oidc:create \
|
|||||||
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
"client_id": "XBd2xqIisu3Kswg39Ub4BUhC36PEYjwwivx3G5nZdDgigvwKXrTHozs7m9DeoLSY",
|
||||||
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
"client_secret": "xNKcy0qpUSau36T60pGGdb03pMEVLXtqykxjK8YkDpoNxNcZ4ClyAT3IAEse2AKT",
|
||||||
"token_type": "jwt",
|
"token_type": "jwt",
|
||||||
"allowed_scopes": "openid profile email 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 │
|
│ 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`)
|
**4. PRM Endpoint** (`nextcloud_mcp_server/app.py:503-532`)
|
||||||
- `GET /.well-known/oauth-protected-resource/mcp`
|
- `GET /.well-known/oauth-protected-resource/mcp`
|
||||||
- Advertises `["mcp:notes:read", "mcp:notes:write"]`
|
- Advertises `["mcp:notes.read", "mcp:notes.write"]`
|
||||||
- RFC 9728 compliant
|
- RFC 9728 compliant
|
||||||
|
|
||||||
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
**5. Exception Handler** (`nextcloud_mcp_server/app.py:540-563`)
|
||||||
@@ -499,7 +499,7 @@ The `NextcloudTokenVerifier` implements a **cascading validation strategy** that
|
|||||||
│ ├─ Authenticate with client credentials
|
│ ├─ Authenticate with client credentials
|
||||||
│ ├─ Response contains:
|
│ ├─ Response contains:
|
||||||
│ │ • active: true/false
|
│ │ • active: true/false
|
||||||
│ │ • scope: "openid mcp:notes:read mcp:notes:write"
|
│ │ • scope: "openid mcp:notes.read mcp:notes.write"
|
||||||
│ │ • sub, exp, iat, client_id
|
│ │ • sub, exp, iat, client_id
|
||||||
│ ├─ Extract scopes from response
|
│ ├─ Extract scopes from response
|
||||||
│ └─ Success: Return AccessToken
|
│ └─ Success: Return AccessToken
|
||||||
@@ -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`)
|
**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
|
**Verifies:** Security - users who decline custom scopes cannot access any MCP tools
|
||||||
|
|
||||||
#### 2. Read-Only Access (36 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
|
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
|
**Expected:** 36 read-only tools visible, write tools hidden
|
||||||
**Verifies:** Read tools accessible, write tools filtered out
|
**Verifies:** Read tools accessible, write tools filtered out
|
||||||
|
|
||||||
@@ -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
|
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
|
**Expected:** 54 write tools visible, read tools hidden
|
||||||
**Verifies:** Write tools accessible, read tools filtered out
|
**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
|
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
|
**Expected:** All 90 tools visible
|
||||||
**Verifies:** Full access when user grants all custom scopes
|
**Verifies:** Full access when user grants all custom scopes
|
||||||
|
|
||||||
### Test Fixtures
|
### Test Fixtures
|
||||||
|
|
||||||
**OAuth Client Fixtures:**
|
**OAuth Client Fixtures:**
|
||||||
- `read_only_oauth_client_credentials` - Client with `mcp:notes:read` only
|
- `read_only_oauth_client_credentials` - Client with `mcp:notes.read` only
|
||||||
- `write_only_oauth_client_credentials` - Client with `mcp:notes:write` only
|
- `write_only_oauth_client_credentials` - Client with `mcp:notes.write` only
|
||||||
- `full_access_oauth_client_credentials` - Client with both scopes
|
- `full_access_oauth_client_credentials` - Client with both scopes
|
||||||
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
- `no_custom_scopes_oauth_client_credentials` - Client with OIDC defaults only
|
||||||
|
|
||||||
**Token Fixtures:**
|
**Token Fixtures:**
|
||||||
- `playwright_oauth_token_read_only` - Obtains token with `mcp:notes:read`
|
- `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_write_only` - Obtains token with `mcp:notes.write`
|
||||||
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
- `playwright_oauth_token_full_access` - Obtains token with both scopes
|
||||||
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
- `playwright_oauth_token_no_custom_scopes` - Obtains token with no custom scopes
|
||||||
|
|
||||||
@@ -681,14 +681,14 @@ docker compose exec app php occ oidc:list
|
|||||||
# If empty, recreate client with --allowed_scopes
|
# If empty, recreate client with --allowed_scopes
|
||||||
docker compose exec app php occ oidc:create \
|
docker compose exec app php occ oidc:create \
|
||||||
--token_type=jwt \
|
--token_type=jwt \
|
||||||
--allowed_scopes="openid profile email mcp:notes:read mcp:notes:write" \
|
--allowed_scopes="openid profile email mcp:notes.read mcp:notes.write" \
|
||||||
"Client Name" \
|
"Client Name" \
|
||||||
"http://callback/url"
|
"http://callback/url"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issue: All Tools Visible Despite Read-Only Token
|
### Issue: All Tools Visible Despite Read-Only Token
|
||||||
|
|
||||||
**Symptom:** User with `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
|
**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 \
|
docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
|
||||||
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
-e "SELECT name, allowed_scopes FROM oc_oauth2_clients WHERE name LIKE 'DCR-%' ORDER BY id DESC LIMIT 1;"
|
||||||
|
|
||||||
# Should show your requested scopes (e.g., "openid profile email 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:**
|
**If scopes are missing:**
|
||||||
@@ -812,7 +812,7 @@ mcp-oauth:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=https://nextcloud.example.com
|
||||||
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
- NEXTCLOUD_OIDC_CLIENT_ID=${JWT_CLIENT_ID}
|
||||||
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
- NEXTCLOUD_OIDC_CLIENT_SECRET=${JWT_CLIENT_SECRET}
|
||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes:read mcp:notes:write
|
- NEXTCLOUD_OIDC_SCOPES=openid profile email mcp:notes.read mcp:notes.write
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
```
|
```
|
||||||
@@ -846,22 +846,22 @@ mcp-oauth:
|
|||||||
```bash
|
```bash
|
||||||
# Success
|
# Success
|
||||||
INFO JWT verified successfully for user: admin
|
INFO JWT verified successfully for user: admin
|
||||||
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes:read', 'mcp:notes:write'}
|
INFO ✅ Extracted scopes from access token: {'openid', 'profile', 'email', 'mcp:notes.read', 'mcp:notes.write'}
|
||||||
|
|
||||||
# Failures
|
# Failures
|
||||||
WARNING JWT issuer validation failed: Invalid issuer
|
WARNING JWT issuer validation failed: Invalid issuer
|
||||||
WARNING Missing required scopes: mcp:notes:write
|
WARNING Missing required scopes: mcp:notes.write
|
||||||
```
|
```
|
||||||
|
|
||||||
### Known Limitations
|
### 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
|
2. **No Refresh Token Support** - Tokens must be reacquired when expired
|
||||||
|
|
||||||
### Future Enhancements
|
### Future Enhancements
|
||||||
|
|
||||||
**Potential Improvements:**
|
**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)
|
- Resource-level filtering (apply to MCP resources, not just tools)
|
||||||
- Automatic scope discovery from decorated tools
|
- Automatic scope discovery from decorated tools
|
||||||
- Admin UI for scope management
|
- Admin UI for scope management
|
||||||
|
|||||||
+43
-43
@@ -89,7 +89,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
|||||||
│ client_id=xxx │ │
|
│ client_id=xxx │ │
|
||||||
│ &code_challenge=abc... │ │
|
│ &code_challenge=abc... │ │
|
||||||
│ &code_challenge_method=S256 │ │
|
│ &code_challenge_method=S256 │ │
|
||||||
│ &scope=openid notes:read ... │ │
|
│ &scope=openid notes.read ... │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ 2c. User consent page │ │
|
│ 2c. User consent page │ │
|
||||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||||
@@ -111,7 +111,7 @@ Phase 2: OAuth Authorization Flow (PKCE - RFC 7636)
|
|||||||
│ 2g. Access token (JWT/opaque) │ │
|
│ 2g. Access token (JWT/opaque) │ │
|
||||||
│<─────────────────────────────────┼─────────────────────────────────────┤
|
│<─────────────────────────────────┼─────────────────────────────────────┤
|
||||||
│ {access_token, token_type, │ │
|
│ {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 │ │
|
│ 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> │ │
|
│ Authorization: Bearer <token> │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 3f. Scope check PASSED │
|
│ │ 3f. Scope check PASSED │
|
||||||
│ │ ✓ Token has notes:read │
|
│ │ ✓ Token has notes.read │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 3g. Nextcloud API call │
|
│ │ 3g. Nextcloud API call │
|
||||||
│ ├────────────────────────────────────>│
|
│ ├────────────────────────────────────>│
|
||||||
@@ -168,17 +168,17 @@ Insufficient Scope Example (Step-Up Authorization)
|
|||||||
|
|
||||||
│ 4a. Call write tool │ │
|
│ 4a. Call write tool │ │
|
||||||
├─────────────────────────────────>│ │
|
├─────────────────────────────────>│ │
|
||||||
│ nc_notes_create_note(...) │ ← @require_scopes("notes:write") │
|
│ nc_notes_create_note(...) │ ← @require_scopes("notes.write") │
|
||||||
│ Authorization: Bearer <token> │ │
|
│ Authorization: Bearer <token> │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ 4b. Scope check FAILED │
|
│ │ 4b. Scope check FAILED │
|
||||||
│ │ ✗ Token only has notes:read │
|
│ │ ✗ Token only has notes.read │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ 4c. 403 Insufficient Scope │ │
|
│ 4c. 403 Insufficient Scope │ │
|
||||||
│<─────────────────────────────────┤ │
|
│<─────────────────────────────────┤ │
|
||||||
│ WWW-Authenticate: Bearer │ │
|
│ WWW-Authenticate: Bearer │ │
|
||||||
│ error="insufficient_scope", │ │
|
│ error="insufficient_scope", │ │
|
||||||
│ scope="notes:write", │ │
|
│ scope="notes.write", │ │
|
||||||
│ resource_metadata="..." │ │
|
│ resource_metadata="..." │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ → Client can re-authorize with │ │
|
│ → 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
|
- `client_id`: OAuth client ID
|
||||||
- `code_challenge`: SHA256 hash of verifier
|
- `code_challenge`: SHA256 hash of verifier
|
||||||
- `code_challenge_method`: `S256`
|
- `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
|
- `redirect_uri`: MCP server callback URL
|
||||||
|
|
||||||
3. **User Consent**
|
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**
|
3. **Dynamic Tool Filtering**
|
||||||
- Server compares token scopes with each tool's `@require_scopes`
|
- Server compares token scopes with each tool's `@require_scopes`
|
||||||
- Only returns tools where user has all required 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**
|
4. **Filtered Tool List**
|
||||||
- Client receives only tools they can use
|
- 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**
|
2. **Scope Validation**
|
||||||
- `@require_scopes` decorator extracts token scopes
|
- `@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 missing → 403 with `WWW-Authenticate` header (step-up auth)
|
||||||
- If present → continues execution
|
- If present → continues execution
|
||||||
|
|
||||||
@@ -443,26 +443,26 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
|
|||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. **Tool Call with Insufficient Scopes**
|
1. **Tool Call with Insufficient Scopes**
|
||||||
- User calls `nc_notes_create_note` (requires `notes:write`)
|
- User calls `nc_notes_create_note` (requires `notes.write`)
|
||||||
- But token only has `notes:read`
|
- But token only has `notes.read`
|
||||||
|
|
||||||
2. **Scope Validation Fails**
|
2. **Scope Validation Fails**
|
||||||
- `@require_scopes("notes:write")` decorator checks token
|
- `@require_scopes("notes.write")` decorator checks token
|
||||||
- Finds `notes:write` missing
|
- Finds `notes.write` missing
|
||||||
|
|
||||||
3. **403 Response with Challenge**
|
3. **403 Response with Challenge**
|
||||||
- Returns `403 Forbidden`
|
- Returns `403 Forbidden`
|
||||||
- Includes `WWW-Authenticate` header:
|
- Includes `WWW-Authenticate` header:
|
||||||
```
|
```
|
||||||
Bearer error="insufficient_scope",
|
Bearer error="insufficient_scope",
|
||||||
scope="notes:write",
|
scope="notes.write",
|
||||||
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
resource_metadata="http://localhost:8000/.well-known/oauth-protected-resource/mcp"
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Client Re-Authorization** (Optional)
|
4. **Client Re-Authorization** (Optional)
|
||||||
- Client can initiate new OAuth flow requesting additional scopes
|
- Client can initiate new OAuth flow requesting additional scopes
|
||||||
- User re-consents with expanded permissions
|
- 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
|
**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)
|
- `email` - Access to user email address (required)
|
||||||
|
|
||||||
#### Notes App
|
#### Notes App
|
||||||
- `notes:read` - Read notes, search notes, get note attachments
|
- `notes.read` - Read notes, search notes, get note attachments
|
||||||
- `notes:write` - Create, update, append to, and delete notes
|
- `notes.write` - Create, update, append to, and delete notes
|
||||||
|
|
||||||
#### Calendar App
|
#### Calendar App
|
||||||
- `calendar:read` - List calendars, read events, search events
|
- `calendar.read` - List calendars, read events, search events
|
||||||
- `calendar:write` - Create, update, and delete calendars and events
|
- `calendar.write` - Create, update, and delete calendars and events
|
||||||
|
|
||||||
#### Calendar Tasks (VTODO)
|
#### Calendar Tasks (VTODO)
|
||||||
- `todo:read` - List and read CalDAV tasks
|
- `todo.read` - List and read CalDAV tasks
|
||||||
- `todo:write` - Create, update, and delete CalDAV tasks
|
- `todo.write` - Create, update, and delete CalDAV tasks
|
||||||
|
|
||||||
#### Contacts App
|
#### Contacts App
|
||||||
- `contacts:read` - List address books and read contacts (CardDAV)
|
- `contacts.read` - List address books and read contacts (CardDAV)
|
||||||
- `contacts:write` - Create, update, and delete address books and contacts
|
- `contacts.write` - Create, update, and delete address books and contacts
|
||||||
|
|
||||||
#### Cookbook App
|
#### Cookbook App
|
||||||
- `cookbook:read` - Read recipes, search recipes
|
- `cookbook.read` - Read recipes, search recipes
|
||||||
- `cookbook:write` - Create, update, and delete recipes
|
- `cookbook.write` - Create, update, and delete recipes
|
||||||
|
|
||||||
#### Deck App
|
#### Deck App
|
||||||
- `deck:read` - List boards, stacks, cards, and labels
|
- `deck.read` - List boards, stacks, cards, and labels
|
||||||
- `deck:write` - Create, update, and delete boards, stacks, cards, and labels
|
- `deck.write` - Create, update, and delete boards, stacks, cards, and labels
|
||||||
|
|
||||||
#### Tables App
|
#### Tables App
|
||||||
- `tables:read` - List tables and read rows
|
- `tables.read` - List tables and read rows
|
||||||
- `tables:write` - Create, update, and delete rows in tables
|
- `tables.write` - Create, update, and delete rows in tables
|
||||||
|
|
||||||
#### Files (WebDAV)
|
#### Files (WebDAV)
|
||||||
- `files:read` - List files, read file contents, search files
|
- `files.read` - List files, read file contents, search files
|
||||||
- `files:write` - Upload, update, move, copy, and delete files
|
- `files.write` - Upload, update, move, copy, and delete files
|
||||||
|
|
||||||
#### Sharing
|
#### Sharing
|
||||||
- `sharing:read` - List shares and read share information
|
- `sharing.read` - List shares and read share information
|
||||||
- `sharing:write` - Create, update, and delete shares
|
- `sharing.write` - Create, update, and delete shares
|
||||||
|
|
||||||
#### Semantic Search (Multi-App Vector Database)
|
#### 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.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.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
|
### Scope Discovery
|
||||||
|
|
||||||
@@ -652,7 +652,7 @@ curl http://localhost:8000/.well-known/oauth-protected-resource/mcp
|
|||||||
# Response includes dynamically discovered scopes
|
# Response includes dynamically discovered scopes
|
||||||
{
|
{
|
||||||
"resource": "http://localhost:8000/mcp",
|
"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"],
|
"authorization_servers": ["https://nextcloud.example.com"],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
"resource_signing_alg_values_supported": ["RS256"]
|
"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
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||||
"""Get a specific note by ID"""
|
"""Get a specific note by ID"""
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -681,7 +681,7 @@ During OAuth client registration (dynamic or manual), clients request a set of s
|
|||||||
|
|
||||||
**Environment Variable**:
|
**Environment Variable**:
|
||||||
```bash
|
```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)
|
**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`
|
1. Tool returns `403 Forbidden` with `InsufficientScopeError`
|
||||||
2. Response includes `WWW-Authenticate` header listing missing scopes:
|
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
|
3. Client can re-authorize with additional scopes
|
||||||
|
|
||||||
@@ -708,9 +708,9 @@ All scope enforcement happens at two levels:
|
|||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
```python
|
```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 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
|
# Attempting to call a write tool returns 403 Forbidden
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -412,7 +412,7 @@ http://localhost:8000/oauth/callback
|
|||||||
**Symptoms**:
|
**Symptoms**:
|
||||||
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
- MCP client (e.g., Claude Code) successfully connects via OAuth
|
||||||
- Only Notes tools are available (7 tools instead of 90+)
|
- 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.
|
**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
|
```bash
|
||||||
# Update allowed scopes for an existing client
|
# Update allowed scopes for an existing client
|
||||||
php occ oidc:update <client_id> \
|
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
|
# 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:
|
# Should show all 16 scope categories:
|
||||||
# - openid
|
# - openid
|
||||||
# - mcp:notes:read, mcp:notes:write
|
# - mcp:notes.read, mcp:notes.write
|
||||||
# - mcp:calendar:read, mcp:calendar:write
|
# - mcp:calendar.read, mcp:calendar.write
|
||||||
# - mcp:contacts:read, mcp:contacts:write
|
# - mcp:contacts.read, mcp:contacts.write
|
||||||
# - mcp:cookbook:read, mcp:cookbook:write
|
# - mcp:cookbook.read, mcp:cookbook.write
|
||||||
# - mcp:deck:read, mcp:deck:write
|
# - mcp:deck.read, mcp:deck.write
|
||||||
# - mcp:tables:read, mcp:tables:write
|
# - mcp:tables.read, mcp:tables.write
|
||||||
# - mcp:files:read, mcp:files:write
|
# - mcp:files.read, mcp:files.write
|
||||||
# - mcp:sharing:read, mcp:sharing:write
|
# - mcp:sharing.read, mcp:sharing.write
|
||||||
```
|
```
|
||||||
|
|
||||||
**Understanding Scope Filtering**:
|
**Understanding Scope Filtering**:
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ sequenceDiagram
|
|||||||
participant NC as Nextcloud API
|
participant NC as Nextcloud API
|
||||||
|
|
||||||
User->>MCP: nc_semantic_search("machine learning")
|
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
|
MCP->>Ollama: Generate query embedding
|
||||||
Ollama-->>MCP: Query vector (768-dim)
|
Ollama-->>MCP: Query vector (768-dim)
|
||||||
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
|
MCP->>Qdrant: Search similar vectors<br/>(filter: user_id=alice)
|
||||||
@@ -319,7 +319,7 @@ sequenceDiagram
|
|||||||
### Dual-Phase Authorization
|
### Dual-Phase Authorization
|
||||||
|
|
||||||
**Phase 1: OAuth Scope Check**
|
**Phase 1: OAuth Scope Check**
|
||||||
- Verify user has `semantic:read` scope
|
- Verify user has `semantic.read` scope
|
||||||
- Rejects unauthorized users immediately
|
- Rejects unauthorized users immediately
|
||||||
|
|
||||||
**Phase 2: Per-Document Verification**
|
**Phase 2: Per-Document Verification**
|
||||||
@@ -419,19 +419,19 @@ except Exception as e:
|
|||||||
|
|
||||||
### OAuth Scopes
|
### OAuth Scopes
|
||||||
|
|
||||||
**`semantic:read`** - Search permission
|
**`semantic.read`** - Search permission
|
||||||
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
|
- Allows using `nc_semantic_search` and `nc_semantic_search_answer` tools
|
||||||
- Does NOT grant access to documents (verified via app APIs)
|
- Does NOT grant access to documents (verified via app APIs)
|
||||||
- Required for any semantic search operation
|
- 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`)
|
- Allows enabling/disabling background sync (`provision_vector_sync`, `deprovision_vector_sync`)
|
||||||
- Controls whether user's documents are indexed
|
- Controls whether user's documents are indexed
|
||||||
- Currently not implemented in OAuth mode (BasicAuth only)
|
- Currently not implemented in OAuth mode (BasicAuth only)
|
||||||
|
|
||||||
### Dual-Phase Authorization Pattern
|
### Dual-Phase Authorization Pattern
|
||||||
|
|
||||||
**Phase 1: Scope Check** (semantic:read)
|
**Phase 1: Scope Check** (semantic.read)
|
||||||
- Verifies user authorized to search
|
- Verifies user authorized to search
|
||||||
- Prevents unauthorized vector database access
|
- Prevents unauthorized vector database access
|
||||||
|
|
||||||
@@ -482,7 +482,7 @@ except Exception as e:
|
|||||||
- Credentials stored in `.env` file (secure server access required)
|
- Credentials stored in `.env` file (secure server access required)
|
||||||
|
|
||||||
**OAuth:**
|
**OAuth:**
|
||||||
- Client authenticates with `semantic:read` scope
|
- Client authenticates with `semantic.read` scope
|
||||||
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
|
- User must explicitly provision offline access (future: `provision_vector_sync` tool)
|
||||||
- Background sync only works for users who provisioned access
|
- Background sync only works for users who provisioned access
|
||||||
- More secure: tokens expire, user controls 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
|
1. Get client_id from the JWT client JSON
|
||||||
2. Visit in browser:
|
2. Visit in browser:
|
||||||
```
|
```
|
||||||
http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=http://localhost: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
|
### 3. Expected Behavior
|
||||||
@@ -203,8 +203,8 @@ http://localhost:8080/apps/oidc/authorize?client_id=YOUR_CLIENT_ID&response_type
|
|||||||
- ✓ Basic authentication (openid) - required, cannot deselect
|
- ✓ Basic authentication (openid) - required, cannot deselect
|
||||||
- ✓ Profile information (profile)
|
- ✓ Profile information (profile)
|
||||||
- ✓ Email address (email)
|
- ✓ Email address (email)
|
||||||
- ✓ mcp:notes:read (custom scope, shown as-is)
|
- ✓ mcp:notes.read (custom scope, shown as-is)
|
||||||
- ✓ mcp:notes:write (custom scope, shown as-is)
|
- ✓ mcp:notes.write (custom scope, shown as-is)
|
||||||
- "Allow" and "Deny" buttons
|
- "Allow" and "Deny" buttons
|
||||||
3. User selects scopes and clicks "Allow"
|
3. User selects scopes and clicks "Allow"
|
||||||
4. Authorization proceeds with selected scopes
|
4. Authorization proceeds with selected scopes
|
||||||
|
|||||||
+54
-54
@@ -383,24 +383,24 @@
|
|||||||
"phone",
|
"phone",
|
||||||
"offline_access",
|
"offline_access",
|
||||||
"microprofile-jwt",
|
"microprofile-jwt",
|
||||||
"notes:read",
|
"notes.read",
|
||||||
"notes:write",
|
"notes.write",
|
||||||
"calendar:read",
|
"calendar.read",
|
||||||
"calendar:write",
|
"calendar.write",
|
||||||
"contacts:read",
|
"contacts.read",
|
||||||
"contacts:write",
|
"contacts.write",
|
||||||
"cookbook:read",
|
"cookbook.read",
|
||||||
"cookbook:write",
|
"cookbook.write",
|
||||||
"deck:read",
|
"deck.read",
|
||||||
"deck:write",
|
"deck.write",
|
||||||
"tables:read",
|
"tables.read",
|
||||||
"tables:write",
|
"tables.write",
|
||||||
"files:read",
|
"files.read",
|
||||||
"files:write",
|
"files.write",
|
||||||
"sharing:read",
|
"sharing.read",
|
||||||
"sharing:write",
|
"sharing.write",
|
||||||
"todo:read",
|
"todo.read",
|
||||||
"todo:write"
|
"todo.write"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -573,7 +573,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "notes:read",
|
"name": "notes.read",
|
||||||
"description": "Nextcloud Notes read access",
|
"description": "Nextcloud Notes read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -583,7 +583,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "notes:write",
|
"name": "notes.write",
|
||||||
"description": "Nextcloud Notes write access",
|
"description": "Nextcloud Notes write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -593,7 +593,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "calendar:read",
|
"name": "calendar.read",
|
||||||
"description": "Nextcloud Calendar read access",
|
"description": "Nextcloud Calendar read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -603,7 +603,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "calendar:write",
|
"name": "calendar.write",
|
||||||
"description": "Nextcloud Calendar write access",
|
"description": "Nextcloud Calendar write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -613,7 +613,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "contacts:read",
|
"name": "contacts.read",
|
||||||
"description": "Nextcloud Contacts read access",
|
"description": "Nextcloud Contacts read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -623,7 +623,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "contacts:write",
|
"name": "contacts.write",
|
||||||
"description": "Nextcloud Contacts write access",
|
"description": "Nextcloud Contacts write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cookbook:read",
|
"name": "cookbook.read",
|
||||||
"description": "Nextcloud Cookbook read access",
|
"description": "Nextcloud Cookbook read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cookbook:write",
|
"name": "cookbook.write",
|
||||||
"description": "Nextcloud Cookbook write access",
|
"description": "Nextcloud Cookbook write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -653,7 +653,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deck:read",
|
"name": "deck.read",
|
||||||
"description": "Nextcloud Deck read access",
|
"description": "Nextcloud Deck read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -663,7 +663,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deck:write",
|
"name": "deck.write",
|
||||||
"description": "Nextcloud Deck write access",
|
"description": "Nextcloud Deck write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -673,7 +673,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tables:read",
|
"name": "tables.read",
|
||||||
"description": "Nextcloud Tables read access",
|
"description": "Nextcloud Tables read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -683,7 +683,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tables:write",
|
"name": "tables.write",
|
||||||
"description": "Nextcloud Tables write access",
|
"description": "Nextcloud Tables write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -693,7 +693,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "files:read",
|
"name": "files.read",
|
||||||
"description": "Nextcloud Files read access",
|
"description": "Nextcloud Files read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -703,7 +703,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "files:write",
|
"name": "files.write",
|
||||||
"description": "Nextcloud Files write access",
|
"description": "Nextcloud Files write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -713,7 +713,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sharing:read",
|
"name": "sharing.read",
|
||||||
"description": "Nextcloud Sharing read access",
|
"description": "Nextcloud Sharing read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -723,7 +723,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sharing:write",
|
"name": "sharing.write",
|
||||||
"description": "Nextcloud Sharing write access",
|
"description": "Nextcloud Sharing write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -733,7 +733,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "todo:read",
|
"name": "todo.read",
|
||||||
"description": "Nextcloud Tasks/Todo read access",
|
"description": "Nextcloud Tasks/Todo read access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -743,7 +743,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "todo:write",
|
"name": "todo.write",
|
||||||
"description": "Nextcloud Tasks/Todo write access",
|
"description": "Nextcloud Tasks/Todo write access",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
"attributes": {
|
"attributes": {
|
||||||
@@ -830,23 +830,23 @@
|
|||||||
],
|
],
|
||||||
"defaultOptionalClientScopes": [
|
"defaultOptionalClientScopes": [
|
||||||
"offline_access",
|
"offline_access",
|
||||||
"notes:read",
|
"notes.read",
|
||||||
"notes:write",
|
"notes.write",
|
||||||
"calendar:read",
|
"calendar.read",
|
||||||
"calendar:write",
|
"calendar.write",
|
||||||
"contacts:read",
|
"contacts.read",
|
||||||
"contacts:write",
|
"contacts.write",
|
||||||
"cookbook:read",
|
"cookbook.read",
|
||||||
"cookbook:write",
|
"cookbook.write",
|
||||||
"deck:read",
|
"deck.read",
|
||||||
"deck:write",
|
"deck.write",
|
||||||
"tables:read",
|
"tables.read",
|
||||||
"tables:write",
|
"tables.write",
|
||||||
"files:read",
|
"files.read",
|
||||||
"files:write",
|
"files.write",
|
||||||
"sharing:read",
|
"sharing.read",
|
||||||
"sharing:write",
|
"sharing.write",
|
||||||
"todo:read",
|
"todo.read",
|
||||||
"todo:write"
|
"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.
|
# These must stay in sync — any scope a tool uses via @require_scopes must be listed here.
|
||||||
dcr_scopes = (
|
dcr_scopes = (
|
||||||
"openid profile email "
|
"openid profile email "
|
||||||
"notes:read notes:write calendar:read calendar:write todo:read todo: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 "
|
"contacts.read contacts.write cookbook.read cookbook.write deck.read deck.write "
|
||||||
"tables:read tables:write files:read files:write sharing:read sharing:write "
|
"tables.read tables.write files.read files.write sharing.read sharing.write "
|
||||||
"news:read news:write collectives:read collectives:write"
|
"news.read news.write collectives.read collectives.write"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add conditional scopes based on server configuration
|
# Add conditional scopes based on server configuration
|
||||||
dcr_settings = get_settings()
|
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:
|
if dcr_settings.vector_sync_enabled:
|
||||||
dcr_scopes = f"{dcr_scopes} semantic:read"
|
dcr_scopes = f"{dcr_scopes} semantic.read"
|
||||||
logger.info("✓ semantic:read scope enabled for semantic search tools")
|
logger.info("✓ semantic.read scope enabled for semantic search tools")
|
||||||
|
|
||||||
# offline_access enables refresh tokens for background operations
|
# offline_access enables refresh tokens for background operations
|
||||||
enable_offline_access = dcr_settings.enable_offline_access
|
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"
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
# Request only basic OIDC scopes for browser session
|
# 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
|
# not for the MCP server's own browser authentication
|
||||||
scopes = "openid profile email offline_access"
|
scopes = "openid profile email offline_access"
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def require_scopes(*required_scopes: str):
|
|||||||
users who lack the necessary scopes.
|
users who lack the necessary scopes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*required_scopes: Variable number of scope strings required (e.g., "notes:read", "notes:write")
|
*required_scopes: Variable number of scope strings required (e.g., "notes.read", "notes.write")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decorated function that checks scopes before execution
|
Decorated function that checks scopes before execution
|
||||||
@@ -82,15 +82,15 @@ def require_scopes(*required_scopes: str):
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
async def nc_notes_get_note(ctx: Context, note_id: int):
|
async def nc_notes_get_note(ctx: Context, note_id: int):
|
||||||
# This tool requires the notes:read scope
|
# This tool requires the notes.read scope
|
||||||
...
|
...
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
async def nc_notes_create_note(ctx: Context, ...):
|
async def nc_notes_create_note(ctx: Context, ...):
|
||||||
# This tool requires the notes:write scope
|
# This tool requires the notes.write scope
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -203,12 +203,12 @@ def require_scopes(*required_scopes: str):
|
|||||||
if any(
|
if any(
|
||||||
s.startswith(prefix)
|
s.startswith(prefix)
|
||||||
for prefix in [
|
for prefix in [
|
||||||
"notes:",
|
"notes.",
|
||||||
"calendar:",
|
"calendar.",
|
||||||
"contacts:",
|
"contacts.",
|
||||||
"files:",
|
"files.",
|
||||||
"tables:",
|
"tables.",
|
||||||
"deck:",
|
"deck.",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -223,12 +223,12 @@ def require_scopes(*required_scopes: str):
|
|||||||
s.startswith(prefix)
|
s.startswith(prefix)
|
||||||
for s in token_scopes
|
for s in token_scopes
|
||||||
for prefix in [
|
for prefix in [
|
||||||
"notes:",
|
"notes.",
|
||||||
"calendar:",
|
"calendar.",
|
||||||
"contacts:",
|
"contacts.",
|
||||||
"files:",
|
"files.",
|
||||||
"tables:",
|
"tables.",
|
||||||
"deck:",
|
"deck.",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,7 +305,7 @@ def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]:
|
|||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
async def my_tool(ctx: Context):
|
async def my_tool(ctx: Context):
|
||||||
has_scopes, missing = check_scopes(ctx, "notes:read", "notes:write")
|
has_scopes, missing = check_scopes(ctx, "notes.read", "notes.write")
|
||||||
if not has_scopes:
|
if not has_scopes:
|
||||||
# Handle missing scopes
|
# Handle missing scopes
|
||||||
...
|
...
|
||||||
@@ -335,11 +335,11 @@ def get_required_scopes(func: Callable) -> list[str]:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@require_scopes("notes:read", "notes:write")
|
@require_scopes("notes.read", "notes.write")
|
||||||
async def my_tool():
|
async def my_tool():
|
||||||
pass
|
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", [])
|
return getattr(func, "_required_scopes", [])
|
||||||
@@ -385,14 +385,14 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool:
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
async def create_note():
|
async def create_note():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
user_scopes = {"notes:read", "notes:write"}
|
user_scopes = {"notes.read", "notes.write"}
|
||||||
can_see = has_required_scopes(create_note, user_scopes) # True
|
can_see = has_required_scopes(create_note, user_scopes) # True
|
||||||
|
|
||||||
limited_user_scopes = {"notes:read"}
|
limited_user_scopes = {"notes.read"}
|
||||||
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
can_see = has_required_scopes(create_note, limited_user_scopes) # False
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -431,17 +431,17 @@ def discover_all_scopes(mcp) -> list[str]:
|
|||||||
mcp = FastMCP("My Server")
|
mcp = FastMCP("My Server")
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
async def get_notes():
|
async def get_notes():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
async def create_note():
|
async def create_note():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
scopes = discover_all_scopes(mcp)
|
scopes = discover_all_scopes(mcp)
|
||||||
# Returns: ["notes:read", "notes:write", "openid", "profile", "email"]
|
# Returns: ["notes.read", "notes.write", "openid", "profile", "email"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ class TokenBrokerService:
|
|||||||
data = {
|
data = {
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": 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_id": self.client_id,
|
||||||
"client_secret": self.client_secret,
|
"client_secret": self.client_secret,
|
||||||
}
|
}
|
||||||
@@ -521,7 +521,7 @@ class TokenBrokerService:
|
|||||||
data = {
|
data = {
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": current_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(
|
response = await client.post(
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ from .app import get_app
|
|||||||
@click.option(
|
@click.option(
|
||||||
"--oauth-scopes",
|
"--oauth-scopes",
|
||||||
envvar="NEXTCLOUD_OIDC_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,
|
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)",
|
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
|
# OAuth mode with custom scopes and JWT tokens
|
||||||
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
$ nextcloud-mcp-server --nextcloud-host=https://cloud.example.com --oauth \\
|
||||||
--oauth-scopes="openid 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)
|
# OAuth with public issuer URL (for Docker/proxy setups)
|
||||||
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
$ nextcloud-mcp-server --nextcloud-host=http://app --oauth \\
|
||||||
|
|||||||
@@ -54,27 +54,27 @@ class UpdateScopesResponse(BaseResponse):
|
|||||||
# All supported application-level scopes (frozenset for O(1) membership tests)
|
# All supported application-level scopes (frozenset for O(1) membership tests)
|
||||||
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"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",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ def instrument_tool(func):
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_create_note(...):
|
async def nc_notes_create_note(...):
|
||||||
...
|
...
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP context
|
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.
|
If not specified, all available scopes are requested.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="List Calendars",
|
title="List Calendars",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
"""List all available calendars for the user"""
|
"""List all available calendars for the user"""
|
||||||
@@ -69,7 +69,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Create Calendar Event",
|
title="Create Calendar Event",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_event(
|
async def nc_calendar_create_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -149,7 +149,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="List Calendar Events",
|
title="List Calendar Events",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_events(
|
async def nc_calendar_list_events(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -269,7 +269,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Get Calendar Event",
|
title="Get Calendar Event",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_event(
|
async def nc_calendar_get_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -285,7 +285,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Update Calendar Event",
|
title="Update Calendar Event",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_event(
|
async def nc_calendar_update_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -364,7 +364,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_event(
|
async def nc_calendar_delete_event(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -379,7 +379,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Create Meeting",
|
title="Create Meeting",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_meeting(
|
async def nc_calendar_create_meeting(
|
||||||
title: str,
|
title: str,
|
||||||
@@ -449,7 +449,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Get Upcoming Events",
|
title="Get Upcoming Events",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_upcoming_events(
|
async def nc_calendar_get_upcoming_events(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -512,7 +512,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Find Availability",
|
title="Find Availability",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_find_availability(
|
async def nc_calendar_find_availability(
|
||||||
duration_minutes: int,
|
duration_minutes: int,
|
||||||
@@ -596,7 +596,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Bulk Calendar Operations",
|
title="Bulk Calendar Operations",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_bulk_operations(
|
async def nc_calendar_bulk_operations(
|
||||||
operation: str, # "update", "delete", "move"
|
operation: str, # "update", "delete", "move"
|
||||||
@@ -849,7 +849,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Manage Calendar",
|
title="Manage Calendar",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_manage_calendar(
|
async def nc_calendar_manage_calendar(
|
||||||
action: str, # "create", "delete", "update", "list"
|
action: str, # "create", "delete", "update", "list"
|
||||||
@@ -922,7 +922,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="List Todo Tasks",
|
title="List Todo Tasks",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo.read", "calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_todos(
|
async def nc_calendar_list_todos(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -971,7 +971,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Create Todo Task",
|
title="Create Todo Task",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo.write", "calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_todo(
|
async def nc_calendar_create_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -1018,7 +1018,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Update Todo Task",
|
title="Update Todo Task",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo.write", "calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_todo(
|
async def nc_calendar_update_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -1084,7 +1084,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo.write", "calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_todo(
|
async def nc_calendar_delete_todo(
|
||||||
calendar_name: str,
|
calendar_name: str,
|
||||||
@@ -1108,7 +1108,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
title="Search Todo Tasks",
|
title="Search Todo Tasks",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo.read", "calendar.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_search_todos(
|
async def nc_calendar_search_todos(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="List Collectives",
|
title="List Collectives",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_collectives(
|
async def collectives_get_collectives(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -66,7 +66,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="List Collective Pages",
|
title="List Collective Pages",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_pages(
|
async def collectives_get_pages(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -90,7 +90,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Get Collective Page",
|
title="Get Collective Page",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_page(
|
async def collectives_get_page(
|
||||||
ctx: Context, collective_id: int, page_id: int
|
ctx: Context, collective_id: int, page_id: int
|
||||||
@@ -138,7 +138,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Search Collective Pages",
|
title="Search Collective Pages",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_search_pages(
|
async def collectives_search_pages(
|
||||||
ctx: Context, collective_id: int, query: str
|
ctx: Context, collective_id: int, query: str
|
||||||
@@ -166,7 +166,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="List Collective Tags",
|
title="List Collective Tags",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_tags(
|
async def collectives_get_tags(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -188,7 +188,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="List Trashed Collective Pages",
|
title="List Trashed Collective Pages",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_trashed_pages(
|
async def collectives_get_trashed_pages(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -212,7 +212,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="List Trashed Collectives",
|
title="List Trashed Collectives",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:read")
|
@require_scopes("collectives.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_get_trashed_collectives(
|
async def collectives_get_trashed_collectives(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -238,7 +238,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Create Collective",
|
title="Create Collective",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_create_collective(
|
async def collectives_create_collective(
|
||||||
ctx: Context, name: str, emoji: str | None = None
|
ctx: Context, name: str, emoji: str | None = None
|
||||||
@@ -263,7 +263,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Set Collective Emoji",
|
title="Set Collective Emoji",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_set_collective_emoji(
|
async def collectives_set_collective_emoji(
|
||||||
ctx: Context, collective_id: int, emoji: str | None = None
|
ctx: Context, collective_id: int, emoji: str | None = None
|
||||||
@@ -295,7 +295,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Trash Collective",
|
title="Trash Collective",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_trash_collective(
|
async def collectives_trash_collective(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -324,7 +324,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=False, openWorldHint=True
|
destructiveHint=True, idempotentHint=False, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_delete_collective(
|
async def collectives_delete_collective(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -353,7 +353,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Restore Collective",
|
title="Restore Collective",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_restore_collective(
|
async def collectives_restore_collective(
|
||||||
ctx: Context, collective_id: int
|
ctx: Context, collective_id: int
|
||||||
@@ -379,7 +379,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Create Collective Page",
|
title="Create Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_create_page(
|
async def collectives_create_page(
|
||||||
ctx: Context, collective_id: int, parent_id: int, title: str
|
ctx: Context, collective_id: int, parent_id: int, title: str
|
||||||
@@ -412,7 +412,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Move Collective Page",
|
title="Move Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_move_page(
|
async def collectives_move_page(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -453,7 +453,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Trash Collective Page",
|
title="Trash Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_trash_page(
|
async def collectives_trash_page(
|
||||||
ctx: Context, collective_id: int, page_id: int
|
ctx: Context, collective_id: int, page_id: int
|
||||||
@@ -484,7 +484,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Restore Collective Page",
|
title="Restore Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_restore_page(
|
async def collectives_restore_page(
|
||||||
ctx: Context, collective_id: int, page_id: int
|
ctx: Context, collective_id: int, page_id: int
|
||||||
@@ -512,7 +512,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Set Collective Page Emoji",
|
title="Set Collective Page Emoji",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_set_page_emoji(
|
async def collectives_set_page_emoji(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -544,7 +544,7 @@ def configure_collectives_tools(mcp: FastMCP):
|
|||||||
title="Create Collective Tag",
|
title="Create Collective Tag",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_create_tag(
|
async def collectives_create_tag(
|
||||||
ctx: Context, collective_id: int, name: str, color: str
|
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",
|
title="Assign Tag to Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_assign_tag(
|
async def collectives_assign_tag(
|
||||||
ctx: Context, collective_id: int, page_id: int, tag_id: int
|
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",
|
title="Remove Tag from Collective Page",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("collectives:write")
|
@require_scopes("collectives.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def collectives_remove_tag(
|
async def collectives_remove_tag(
|
||||||
ctx: Context, collective_id: int, page_id: int, tag_id: int
|
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",
|
title="List Address Books",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
|
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
|
||||||
"""List all addressbooks for the user."""
|
"""List all addressbooks for the user."""
|
||||||
@@ -123,7 +123,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
title="List Contacts",
|
title="List Contacts",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_contacts(
|
async def nc_contacts_list_contacts(
|
||||||
ctx: Context, *, addressbook: str
|
ctx: Context, *, addressbook: str
|
||||||
@@ -146,7 +146,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
title="Create Address Book",
|
title="Create Address Book",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_addressbook(
|
async def nc_contacts_create_addressbook(
|
||||||
ctx: Context, *, name: str, display_name: str
|
ctx: Context, *, name: str, display_name: str
|
||||||
@@ -168,7 +168,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
"""Delete an addressbook."""
|
"""Delete an addressbook."""
|
||||||
@@ -179,7 +179,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
title="Create Contact",
|
title="Create Contact",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_contact(
|
async def nc_contacts_create_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict
|
||||||
@@ -204,7 +204,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
"""Delete a contact.
|
"""Delete a contact.
|
||||||
@@ -222,7 +222,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
title="Update Contact",
|
title="Update Contact",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_update_contact(
|
async def nc_contacts_update_contact(
|
||||||
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Import Recipe from URL",
|
title="Import Recipe from URL",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||||
"""Import a recipe from a URL using schema.org metadata.
|
"""Import a recipe from a URL using schema.org metadata.
|
||||||
@@ -136,7 +136,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="List Recipes",
|
title="List Recipes",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||||
"""Get all recipes in the database"""
|
"""Get all recipes in the database"""
|
||||||
@@ -165,7 +165,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Get Recipe",
|
title="Get Recipe",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||||
"""Get a specific recipe by its ID"""
|
"""Get a specific recipe by its ID"""
|
||||||
@@ -194,7 +194,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Create Recipe",
|
title="Create Recipe",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_create_recipe(
|
async def nc_cookbook_create_recipe(
|
||||||
name: str,
|
name: str,
|
||||||
@@ -277,7 +277,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Update Recipe",
|
title="Update Recipe",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_update_recipe(
|
async def nc_cookbook_update_recipe(
|
||||||
recipe_id: int,
|
recipe_id: int,
|
||||||
@@ -372,7 +372,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_delete_recipe(
|
async def nc_cookbook_delete_recipe(
|
||||||
recipe_id: int, ctx: Context
|
recipe_id: int, ctx: Context
|
||||||
@@ -411,7 +411,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Search Recipes",
|
title="Search Recipes",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_search_recipes(
|
async def nc_cookbook_search_recipes(
|
||||||
query: str, ctx: Context
|
query: str, ctx: Context
|
||||||
@@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="List Recipe Categories",
|
title="List Recipe Categories",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||||
"""Get all known categories.
|
"""Get all known categories.
|
||||||
@@ -482,7 +482,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Get Recipes in Category",
|
title="Get Recipes in Category",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_in_category(
|
async def nc_cookbook_get_recipes_in_category(
|
||||||
category: str, ctx: Context
|
category: str, ctx: Context
|
||||||
@@ -522,7 +522,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="List Recipe Keywords",
|
title="List Recipe Keywords",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||||
"""Get all known keywords/tags"""
|
"""Get all known keywords/tags"""
|
||||||
@@ -551,7 +551,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Get Recipes with Keywords",
|
title="Get Recipes with Keywords",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_with_keywords(
|
async def nc_cookbook_get_recipes_with_keywords(
|
||||||
keywords: list[str], ctx: Context
|
keywords: list[str], ctx: Context
|
||||||
@@ -589,7 +589,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Set Cookbook Configuration",
|
title="Set Cookbook Configuration",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_set_config(
|
async def nc_cookbook_set_config(
|
||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
@@ -636,7 +636,7 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
title="Reindex Recipes",
|
title="Reindex Recipes",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||||
"""Trigger a rescan of all recipes into the caching database.
|
"""Trigger a rescan of all recipes into the caching database.
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="List Deck Boards",
|
title="List Deck Boards",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
@@ -138,7 +138,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Get Deck Board",
|
title="Get Deck Board",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
"""Get details of a specific Nextcloud Deck board"""
|
"""Get details of a specific Nextcloud Deck board"""
|
||||||
@@ -150,7 +150,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="List Deck Stacks",
|
title="List Deck Stacks",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
@@ -162,7 +162,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Get Deck Stack",
|
title="Get Deck Stack",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
"""Get details of a specific Nextcloud Deck stack"""
|
"""Get details of a specific Nextcloud Deck stack"""
|
||||||
@@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="List Deck Cards",
|
title="List Deck Cards",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Get Deck Card",
|
title="Get Deck Card",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_card(
|
async def deck_get_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
@@ -203,7 +203,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="List Deck Labels",
|
title="List Deck Labels",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
||||||
"""Get all labels in a Nextcloud Deck board"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
@@ -216,7 +216,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Get Deck Label",
|
title="Get Deck Label",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
"""Get details of a specific Nextcloud Deck label"""
|
"""Get details of a specific Nextcloud Deck label"""
|
||||||
@@ -230,7 +230,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Create Deck Board",
|
title="Create Deck Board",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_board(
|
async def deck_create_board(
|
||||||
ctx: Context, title: str, color: str
|
ctx: Context, title: str, color: str
|
||||||
@@ -251,7 +251,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Create Deck Stack",
|
title="Create Deck Stack",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_stack(
|
async def deck_create_stack(
|
||||||
ctx: Context, board_id: int, title: str, order: int
|
ctx: Context, board_id: int, title: str, order: int
|
||||||
@@ -271,7 +271,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Update Deck Stack",
|
title="Update Deck Stack",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_stack(
|
async def deck_update_stack(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -303,7 +303,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_stack(
|
async def deck_delete_stack(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
@@ -328,7 +328,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Create Deck Card",
|
title="Create Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_card(
|
async def deck_create_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -366,7 +366,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Update Deck Card",
|
title="Update Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_card(
|
async def deck_update_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -425,7 +425,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_card(
|
async def deck_delete_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
@@ -451,7 +451,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Archive Deck Card",
|
title="Archive Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_archive_card(
|
async def deck_archive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
@@ -477,7 +477,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Unarchive Deck Card",
|
title="Unarchive Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unarchive_card(
|
async def deck_unarchive_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
@@ -503,7 +503,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Reorder/Move Deck Card",
|
title="Reorder/Move Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_reorder_card(
|
async def deck_reorder_card(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -539,7 +539,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Create Deck Label",
|
title="Create Deck Label",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_label(
|
async def deck_create_label(
|
||||||
ctx: Context, board_id: int, title: str, color: str
|
ctx: Context, board_id: int, title: str, color: str
|
||||||
@@ -559,7 +559,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Update Deck Label",
|
title="Update Deck Label",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_label(
|
async def deck_update_label(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -591,7 +591,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_label(
|
async def deck_delete_label(
|
||||||
ctx: Context, board_id: int, label_id: int
|
ctx: Context, board_id: int, label_id: int
|
||||||
@@ -616,7 +616,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Assign Label to Deck Card",
|
title="Assign Label to Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_label_to_card(
|
async def deck_assign_label_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
@@ -643,7 +643,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Remove Label from Deck Card",
|
title="Remove Label from Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_remove_label_from_card(
|
async def deck_remove_label_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int
|
||||||
@@ -671,7 +671,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title="Assign User to Deck Card",
|
title="Assign User to Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_user_to_card(
|
async def deck_assign_user_to_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
@@ -700,7 +700,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unassign_user_from_card(
|
async def deck_unassign_user_from_card(
|
||||||
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="List News Folders",
|
title="List News Folders",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
|
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)
|
client = await get_client(ctx)
|
||||||
try:
|
try:
|
||||||
folders_data = await client.news.get_folders()
|
folders_data = await client.news.get_folders()
|
||||||
@@ -59,10 +59,10 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="List News Feeds",
|
title="List News Feeds",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
|
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.
|
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",
|
title="List News Items",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_list_items(
|
async def nc_news_list_items(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -103,7 +103,7 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> ListItemsResponse:
|
) -> ListItemsResponse:
|
||||||
"""List News items (articles) with optional filtering (requires news:read scope).
|
"""List News items (articles) with optional filtering (requires news.read scope).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
feed_id: Filter by specific feed ID
|
feed_id: Filter by specific feed ID
|
||||||
@@ -166,10 +166,10 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="Get News Item",
|
title="Get News Item",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
|
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:
|
Args:
|
||||||
item_id: Item ID
|
item_id: Item ID
|
||||||
@@ -204,12 +204,12 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="Get Starred News Items",
|
title="Get Starred News Items",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_get_starred_items(
|
async def nc_news_get_starred_items(
|
||||||
ctx: Context, limit: int = 50, offset: int = 0
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
) -> ListItemsResponse:
|
) -> 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.
|
Convenience method for retrieving user's starred articles.
|
||||||
|
|
||||||
@@ -257,12 +257,12 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="Get Unread News Items",
|
title="Get Unread News Items",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_get_unread_items(
|
async def nc_news_get_unread_items(
|
||||||
ctx: Context, limit: int = 50, offset: int = 0
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
) -> ListItemsResponse:
|
) -> 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.
|
Convenience method for retrieving unread articles across all feeds.
|
||||||
|
|
||||||
@@ -310,10 +310,10 @@ def configure_news_tools(mcp: FastMCP):
|
|||||||
title="Get News Feed Health",
|
title="Get News Feed Health",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
|
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.
|
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",
|
title="Get News App Status",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("news:read")
|
@require_scopes("news.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
|
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.
|
Returns version information and any configuration warnings.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -92,12 +92,12 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
title: str, content: str, category: str, ctx: Context
|
title: str, content: str, category: str, ctx: Context
|
||||||
) -> CreateNoteResponse:
|
) -> CreateNoteResponse:
|
||||||
"""Create a new note (requires notes:write scope)"""
|
"""Create a new note (requires notes.write scope)"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.create_note(
|
note_data = await client.notes.create_note(
|
||||||
@@ -145,7 +145,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_update_note(
|
async def nc_notes_update_note(
|
||||||
note_id: int,
|
note_id: int,
|
||||||
@@ -155,7 +155,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
category: str | None,
|
category: str | None,
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
) -> UpdateNoteResponse:
|
) -> UpdateNoteResponse:
|
||||||
"""Update an existing note's title, content, or category (requires 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.
|
REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes.
|
||||||
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
Get the current ETag by first retrieving the note using nc_notes_get_note tool.
|
||||||
@@ -217,7 +217,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
note_id: int, content: str, ctx: Context
|
note_id: int, content: str, ctx: Context
|
||||||
@@ -274,10 +274,10 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
|
"""Search notes by title or content, returning only id, title, and category (requires notes.read scope)."""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
try:
|
try:
|
||||||
search_results_raw = await client.notes_search_notes(query=query)
|
search_results_raw = await client.notes_search_notes(query=query)
|
||||||
@@ -327,10 +327,10 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
"""Get a specific note by its ID (requires notes:read scope)"""
|
"""Get a specific note by its ID (requires notes.read scope)"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
@@ -363,7 +363,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_attachment(
|
async def nc_notes_get_attachment(
|
||||||
note_id: int, attachment_filename: str, ctx: Context
|
note_id: int, attachment_filename: str, ctx: Context
|
||||||
@@ -417,7 +417,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
"""Delete a note permanently"""
|
"""Delete a note permanently"""
|
||||||
|
|||||||
@@ -473,14 +473,14 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
|||||||
"profile",
|
"profile",
|
||||||
"email",
|
"email",
|
||||||
"offline_access", # Critical for background operations
|
"offline_access", # Critical for background operations
|
||||||
"notes:read",
|
"notes.read",
|
||||||
"notes:write",
|
"notes.write",
|
||||||
"calendar:read",
|
"calendar.read",
|
||||||
"calendar:write",
|
"calendar.write",
|
||||||
"contacts:read",
|
"contacts.read",
|
||||||
"contacts:write",
|
"contacts.write",
|
||||||
"files:read",
|
"files.read",
|
||||||
"files:write",
|
"files.write",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Generate authorization URL
|
# Generate authorization URL
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True, # Queries external Nextcloud service
|
openWorldHint=True, # Queries external Nextcloud service
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search(
|
async def nc_semantic_search(
|
||||||
query: str,
|
query: str,
|
||||||
@@ -303,7 +303,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
openWorldHint=False, # Searches only indexed Nextcloud data
|
openWorldHint=False, # Searches only indexed Nextcloud data
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search_answer(
|
async def nc_semantic_search_answer(
|
||||||
query: str,
|
query: str,
|
||||||
@@ -645,7 +645,7 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||||
"""Get the current vector sync status.
|
"""Get the current vector sync status.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
title="Create Share",
|
title="Create Share",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_create(
|
async def nc_share_create(
|
||||||
path: str,
|
path: str,
|
||||||
@@ -66,7 +66,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||||
"""Delete a share by its ID.
|
"""Delete a share by its ID.
|
||||||
@@ -89,7 +89,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
title="Get Share Details",
|
title="Get Share Details",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||||
"""Get information about a specific share.
|
"""Get information about a specific share.
|
||||||
@@ -111,7 +111,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
title="List Shares",
|
title="List Shares",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_list(
|
async def nc_share_list(
|
||||||
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
ctx: Context, path: str | None = None, shared_with_me: bool = False
|
||||||
@@ -136,7 +136,7 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
title="Update Share",
|
title="Update Share",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||||
"""Update the permissions of an existing share.
|
"""Update the permissions of an existing share.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
title="List Tables",
|
title="List Tables",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
|
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
@@ -30,7 +30,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
title="Get Table Schema",
|
title="Get Table Schema",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
"""Get the schema/structure of a specific table including columns and views"""
|
"""Get the schema/structure of a specific table including columns and views"""
|
||||||
@@ -41,7 +41,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
title="Read Table Rows",
|
title="Read Table Rows",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_read_table(
|
async def nc_tables_read_table(
|
||||||
table_id: int,
|
table_id: int,
|
||||||
@@ -57,7 +57,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
title="Insert Table Row",
|
title="Insert Table Row",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||||
"""Insert a new row into a table.
|
"""Insert a new row into a table.
|
||||||
@@ -71,7 +71,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
title="Update Table Row",
|
title="Update Table Row",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||||
"""Update an existing row in a table.
|
"""Update an existing row in a table.
|
||||||
@@ -87,7 +87,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
"""Delete a row from a table"""
|
"""Delete a row from a table"""
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_directory(
|
async def nc_webdav_list_directory(
|
||||||
ctx: Context, path: str = ""
|
ctx: Context, path: str = ""
|
||||||
@@ -65,7 +65,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
"""Read the content of a file from NextCloud.
|
"""Read the content of a file from NextCloud.
|
||||||
@@ -137,7 +137,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_write_file(
|
async def nc_webdav_write_file(
|
||||||
path: str, content: str, ctx: Context, content_type: str | None = None
|
path: str, content: str, ctx: Context, content_type: str | None = None
|
||||||
@@ -170,7 +170,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
"""Create a directory in NextCloud.
|
"""Create a directory in NextCloud.
|
||||||
@@ -192,7 +192,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
"""Delete a file or directory in NextCloud.
|
"""Delete a file or directory in NextCloud.
|
||||||
@@ -213,7 +213,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_move_resource(
|
async def nc_webdav_move_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
@@ -240,7 +240,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files.write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_copy_resource(
|
async def nc_webdav_copy_resource(
|
||||||
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
source_path: str, destination_path: str, ctx: Context, overwrite: bool = False
|
||||||
@@ -267,7 +267,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_search_files(
|
async def nc_webdav_search_files(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
@@ -390,7 +390,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_name(
|
async def nc_webdav_find_by_name(
|
||||||
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
pattern: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
@@ -424,7 +424,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_type(
|
async def nc_webdav_find_by_type(
|
||||||
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
mime_type: str, ctx: Context, scope: str = "", limit: int | None = None
|
||||||
@@ -458,7 +458,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
openWorldHint=True,
|
openWorldHint=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files.read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_favorites(
|
async def nc_webdav_list_favorites(
|
||||||
ctx: Context, scope: str = "", limit: int | None = None
|
ctx: Context, scope: str = "", limit: int | None = None
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Scopes required for vector sync operations
|
# Scopes required for vector sync operations
|
||||||
VECTOR_SYNC_SCOPES = [
|
VECTOR_SYNC_SCOPES = [
|
||||||
"notes:read",
|
"notes.read",
|
||||||
"files:read",
|
"files.read",
|
||||||
"deck:read",
|
"deck.read",
|
||||||
# "news:read", # News app may not be installed
|
# "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 scopes for OAuth testing - all app-specific read/write scopes
|
||||||
DEFAULT_FULL_SCOPES = (
|
DEFAULT_FULL_SCOPES = (
|
||||||
"openid profile email "
|
"openid profile email "
|
||||||
"notes:read notes:write "
|
"notes.read notes.write "
|
||||||
"calendar:read calendar:write "
|
"calendar.read calendar.write "
|
||||||
"todo:read todo:write "
|
"todo.read todo.write "
|
||||||
"contacts:read contacts:write "
|
"contacts.read contacts.write "
|
||||||
"cookbook:read cookbook:write "
|
"cookbook.read cookbook.write "
|
||||||
"deck:read deck:write "
|
"deck.read deck.write "
|
||||||
"tables:read tables:write "
|
"tables.read tables.write "
|
||||||
"files:read files:write "
|
"files.read files.write "
|
||||||
"sharing:read sharing:write"
|
"sharing.read sharing.write"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion
|
# Read-only scopes (all read scopes across apps) - should match DEFAULT_FULL_SCOPES read portion
|
||||||
DEFAULT_READ_SCOPES = (
|
DEFAULT_READ_SCOPES = (
|
||||||
"openid profile email "
|
"openid profile email "
|
||||||
"notes:read "
|
"notes.read "
|
||||||
"calendar:read "
|
"calendar.read "
|
||||||
"todo:read "
|
"todo.read "
|
||||||
"contacts:read "
|
"contacts.read "
|
||||||
"cookbook:read "
|
"cookbook.read "
|
||||||
"deck:read "
|
"deck.read "
|
||||||
"tables:read "
|
"tables.read "
|
||||||
"files:read "
|
"files.read "
|
||||||
"sharing:read"
|
"sharing.read"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion
|
# Write-only scopes (all write scopes across apps) - should match DEFAULT_FULL_SCOPES write portion
|
||||||
DEFAULT_WRITE_SCOPES = (
|
DEFAULT_WRITE_SCOPES = (
|
||||||
"openid profile email "
|
"openid profile email "
|
||||||
"notes:write "
|
"notes.write "
|
||||||
"calendar:write "
|
"calendar.write "
|
||||||
"todo:write "
|
"todo.write "
|
||||||
"contacts:write "
|
"contacts.write "
|
||||||
"cookbook:write "
|
"cookbook.write "
|
||||||
"deck:write "
|
"deck.write "
|
||||||
"tables:write "
|
"tables.write "
|
||||||
"files:write "
|
"files.write "
|
||||||
"sharing: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.
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
This client has only OIDC default scopes (openid, profile, email) without
|
This client has only OIDC default scopes (openid, profile, email) without
|
||||||
application-specific scopes (notes:read, notes:write, etc.).
|
application-specific scopes (notes.read, notes.write, etc.).
|
||||||
|
|
||||||
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
Expected behavior: Should see 0 tools (all tools require custom scopes).
|
||||||
|
|
||||||
@@ -1671,7 +1671,7 @@ async def no_custom_scopes_oauth_client_credentials(
|
|||||||
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
Fixture for OAuth client with NO custom scopes (only OIDC defaults).
|
||||||
|
|
||||||
Tests the security behavior when a user grants only the default OIDC scopes
|
Tests the security behavior when a user grants only the default OIDC scopes
|
||||||
(openid, profile, email) but declines custom application scopes (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.
|
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"client_id={client_id}&"
|
||||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||||
f"state={state}&"
|
f"state={state}&"
|
||||||
f"scope=openid%20profile%20email%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
|
# Add resource parameter (RFC 8707) if available
|
||||||
@@ -2060,7 +2060,7 @@ async def _get_oauth_token_with_scopes(
|
|||||||
browser: Playwright browser instance
|
browser: Playwright browser instance
|
||||||
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
shared_oauth_client_credentials: Tuple of OAuth client credentials
|
||||||
oauth_callback_server: OAuth callback server fixture
|
oauth_callback_server: OAuth callback server fixture
|
||||||
scopes: Space-separated list of scopes (e.g., "openid profile email notes:read")
|
scopes: Space-separated list of scopes (e.g., "openid profile email notes.read")
|
||||||
resource: Optional resource parameter (RFC 8707) for token audience
|
resource: Optional resource parameter (RFC 8707) for token audience
|
||||||
mcp_server_base_url: Base URL of the MCP server for resource metadata discovery
|
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"redirect_uri={quote(callback_url, safe='')}&"
|
||||||
f"state={state}&"
|
f"state={state}&"
|
||||||
f"resource={quote(mcp_server_resource, safe='')}&" # Resource URI from PRM
|
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}...")
|
logger.info(f"Performing browser OAuth flow for {username}...")
|
||||||
@@ -3038,7 +3038,7 @@ async def configure_astrolabe_for_mcp_server(nc_client):
|
|||||||
"--resource_url",
|
"--resource_url",
|
||||||
mcp_server_public_url,
|
mcp_server_public_url,
|
||||||
"--allowed_scopes",
|
"--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,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async def get_oauth_token_with_client(
|
|||||||
authorization_endpoint: str,
|
authorization_endpoint: str,
|
||||||
callback_url: str,
|
callback_url: str,
|
||||||
auth_states: dict,
|
auth_states: dict,
|
||||||
scopes: str = "openid profile email notes:read notes:write",
|
scopes: str = "openid profile email notes.read notes.write",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Helper to obtain OAuth access token using existing client credentials.
|
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",
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
"scope": "openid profile email notes:read",
|
"scope": "openid profile email notes.read",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
|||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
client_name="DCR Lifecycle Test Client 2",
|
client_name="DCR Lifecycle Test Client 2",
|
||||||
redirect_uris=[callback_url],
|
redirect_uris=[callback_url],
|
||||||
scopes="openid profile email notes:read",
|
scopes="openid profile email notes.read",
|
||||||
token_type="Bearer",
|
token_type="Bearer",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ async def test_dcr_register_and_delete_lifecycle(
|
|||||||
authorization_endpoint=authorization_endpoint,
|
authorization_endpoint=authorization_endpoint,
|
||||||
callback_url=callback_url,
|
callback_url=callback_url,
|
||||||
auth_states=auth_states,
|
auth_states=auth_states,
|
||||||
scopes="openid profile email notes:read",
|
scopes="openid profile email notes.read",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert access_token, "Failed to obtain access token"
|
assert access_token, "Failed to obtain access token"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async def get_oauth_token_with_client(
|
|||||||
authorization_endpoint: str,
|
authorization_endpoint: str,
|
||||||
callback_url: str,
|
callback_url: str,
|
||||||
auth_states: dict,
|
auth_states: dict,
|
||||||
scopes: str = "openid profile email notes:read notes:write",
|
scopes: str = "openid profile email notes.read notes.write",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Helper to obtain OAuth access token using existing client credentials.
|
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,
|
registration_endpoint=registration_endpoint,
|
||||||
client_name="DCR Test - JWT Token Type",
|
client_name="DCR Test - JWT Token Type",
|
||||||
redirect_uris=[callback_url],
|
redirect_uris=[callback_url],
|
||||||
scopes="openid profile email notes:read notes:write",
|
scopes="openid profile email notes.read notes.write",
|
||||||
token_type="jwt",
|
token_type="jwt",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -276,8 +276,8 @@ async def test_dcr_respects_jwt_token_type(
|
|||||||
# Verify scope claim exists (critical for MCP tool filtering)
|
# Verify scope claim exists (critical for MCP tool filtering)
|
||||||
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
assert "scope" in payload, "JWT payload missing 'scope' claim"
|
||||||
scopes = payload["scope"].split()
|
scopes = payload["scope"].split()
|
||||||
assert "notes:read" in scopes, "JWT scope claim missing notes:read"
|
assert "notes.read" in scopes, "JWT scope claim missing notes.read"
|
||||||
assert "notes:write" in scopes, "JWT scope claim missing notes:write"
|
assert "notes.write" in scopes, "JWT scope claim missing notes.write"
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ DCR with token_type=jwt works correctly! "
|
f"✅ DCR with token_type=jwt works correctly! "
|
||||||
@@ -325,7 +325,7 @@ async def test_dcr_respects_bearer_token_type(
|
|||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
client_name="DCR Test - Opaque Token Type",
|
client_name="DCR Test - Opaque Token Type",
|
||||||
redirect_uris=[callback_url],
|
redirect_uris=[callback_url],
|
||||||
scopes="openid profile email notes:read notes:write",
|
scopes="openid profile email notes.read notes.write",
|
||||||
token_type="opaque",
|
token_type="opaque",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ async def test_prm_endpoint():
|
|||||||
|
|
||||||
prm_data = response.json()
|
prm_data = response.json()
|
||||||
assert prm_data["resource"] == "http://localhost:8004/mcp"
|
assert prm_data["resource"] == "http://localhost:8004/mcp"
|
||||||
assert "notes:read" in prm_data["scopes_supported"]
|
assert "notes.read" in prm_data["scopes_supported"]
|
||||||
assert "notes:write" in prm_data["scopes_supported"]
|
assert "notes.write" in prm_data["scopes_supported"]
|
||||||
assert "http://localhost:8004" in prm_data["authorization_servers"]
|
assert "http://localhost:8004" in prm_data["authorization_servers"]
|
||||||
assert "header" in prm_data["bearer_methods_supported"]
|
assert "header" in prm_data["bearer_methods_supported"]
|
||||||
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
assert "RS256" in prm_data["resource_signing_alg_values_supported"]
|
||||||
@@ -67,7 +67,7 @@ async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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()
|
result = await nc_mcp_login_flow_client_read_only.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
@@ -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")
|
logger.info(f"Read-only token sees {len(tool_names)} tools")
|
||||||
|
|
||||||
# Verify read tools are present (only for apps with :read scopes)
|
# Verify read tools are present (only for apps with :read scopes)
|
||||||
# Read-only token has: notes:read, calendar:read, contacts:read,
|
# Read-only token has: notes.read, calendar.read, contacts.read,
|
||||||
# cookbook:read, deck:read, tables:read, files:read, sharing:read
|
# cookbook.read, deck.read, tables.read, files.read, sharing.read
|
||||||
expected_read_tools = [
|
expected_read_tools = [
|
||||||
"nc_notes_get_note", # notes:read
|
"nc_notes_get_note", # notes.read
|
||||||
"nc_notes_search_notes", # notes:read
|
"nc_notes_search_notes", # notes.read
|
||||||
"nc_calendar_list_calendars", # calendar:read
|
"nc_calendar_list_calendars", # calendar.read
|
||||||
"nc_calendar_get_event", # calendar:read
|
"nc_calendar_get_event", # calendar.read
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_read_tools:
|
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)
|
# Verify write tools are NOT present (filtered out)
|
||||||
write_tools_should_be_filtered = [
|
write_tools_should_be_filtered = [
|
||||||
"nc_notes_create_note", # notes:write
|
"nc_notes_create_note", # notes.write
|
||||||
"nc_notes_update_note", # notes:write
|
"nc_notes_update_note", # notes.write
|
||||||
"nc_notes_delete_note", # notes:write
|
"nc_notes_delete_note", # notes.write
|
||||||
"nc_calendar_create_event", # calendar:write
|
"nc_calendar_create_event", # calendar.write
|
||||||
"nc_calendar_update_event", # calendar:write
|
"nc_calendar_update_event", # calendar.write
|
||||||
"nc_calendar_delete_event", # calendar:write
|
"nc_calendar_delete_event", # calendar.write
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in write_tools_should_be_filtered:
|
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__)
|
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()
|
result = await nc_mcp_login_flow_client_write_only.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
@@ -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")
|
logger.info(f"Write-only token sees {len(tool_names)} tools")
|
||||||
|
|
||||||
# Verify write tools are present
|
# Verify write tools are present
|
||||||
# Write-only token has: notes:write, calendar:write, contacts:write,
|
# Write-only token has: notes.write, calendar.write, contacts.write,
|
||||||
# cookbook:write, deck:write, tables:write, files:write, sharing:write
|
# cookbook.write, deck.write, tables.write, files.write, sharing.write
|
||||||
expected_write_tools = [
|
expected_write_tools = [
|
||||||
"nc_notes_create_note", # notes:write
|
"nc_notes_create_note", # notes.write
|
||||||
"nc_notes_update_note", # notes:write
|
"nc_notes_update_note", # notes.write
|
||||||
"nc_notes_delete_note", # notes:write
|
"nc_notes_delete_note", # notes.write
|
||||||
"nc_calendar_create_event", # calendar:write
|
"nc_calendar_create_event", # calendar.write
|
||||||
"nc_calendar_update_event", # calendar:write
|
"nc_calendar_update_event", # calendar.write
|
||||||
"nc_calendar_delete_event", # calendar:write
|
"nc_calendar_delete_event", # calendar.write
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_write_tools:
|
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)
|
# Verify read-only tools are NOT present (write-only scope)
|
||||||
read_tools_should_be_filtered = [
|
read_tools_should_be_filtered = [
|
||||||
"nc_notes_get_note", # notes:read
|
"nc_notes_get_note", # notes.read
|
||||||
"nc_notes_search_notes", # notes:read
|
"nc_notes_search_notes", # notes.read
|
||||||
"nc_calendar_list_calendars", # calendar:read
|
"nc_calendar_list_calendars", # calendar.read
|
||||||
"nc_calendar_get_event", # calendar:read
|
"nc_calendar_get_event", # calendar.read
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in read_tools_should_be_filtered:
|
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__)
|
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()
|
result = await nc_mcp_login_flow_client_full_access.list_tools()
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert len(result.tools) > 0
|
assert len(result.tools) > 0
|
||||||
@@ -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
|
# Verify both read and write tools are present
|
||||||
# Full access has all *read and *write scopes
|
# Full access has all *read and *write scopes
|
||||||
expected_read_tools = [
|
expected_read_tools = [
|
||||||
"nc_notes_get_note", # notes:read
|
"nc_notes_get_note", # notes.read
|
||||||
"nc_notes_search_notes", # notes:read
|
"nc_notes_search_notes", # notes.read
|
||||||
"nc_calendar_list_calendars", # calendar:read
|
"nc_calendar_list_calendars", # calendar.read
|
||||||
]
|
]
|
||||||
|
|
||||||
expected_write_tools = [
|
expected_write_tools = [
|
||||||
"nc_notes_create_note", # notes:write
|
"nc_notes_create_note", # notes.write
|
||||||
"nc_calendar_create_event", # calendar:write
|
"nc_calendar_create_event", # calendar.write
|
||||||
]
|
]
|
||||||
|
|
||||||
for tool in expected_read_tools:
|
for tool in expected_read_tools:
|
||||||
@@ -217,17 +217,17 @@ async def test_scope_helper_functions():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Add scope metadata
|
# Add scope metadata
|
||||||
mock_read_tool._required_scopes = ["notes:read"] # type: ignore
|
mock_read_tool._required_scopes = ["notes.read"] # type: ignore
|
||||||
mock_write_tool._required_scopes = ["notes:write"] # type: ignore
|
mock_write_tool._required_scopes = ["notes.write"] # type: ignore
|
||||||
|
|
||||||
# Test get_required_scopes
|
# Test get_required_scopes
|
||||||
assert get_required_scopes(mock_read_tool) == ["notes:read"]
|
assert get_required_scopes(mock_read_tool) == ["notes.read"]
|
||||||
assert get_required_scopes(mock_write_tool) == ["notes:write"]
|
assert get_required_scopes(mock_write_tool) == ["notes.write"]
|
||||||
assert get_required_scopes(mock_no_scope_tool) == []
|
assert get_required_scopes(mock_no_scope_tool) == []
|
||||||
|
|
||||||
# Test has_required_scopes
|
# Test has_required_scopes
|
||||||
read_only_scopes = {"notes:read"}
|
read_only_scopes = {"notes.read"}
|
||||||
full_scopes = {"notes:read", "notes:write"}
|
full_scopes = {"notes.read", "notes.write"}
|
||||||
no_scopes = set()
|
no_scopes = set()
|
||||||
|
|
||||||
# User with only read scope
|
# User with only read scope
|
||||||
@@ -251,13 +251,13 @@ async def test_scope_decorator_stores_metadata():
|
|||||||
"""Test that @require_scopes decorator properly stores metadata."""
|
"""Test that @require_scopes decorator properly stores metadata."""
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
|
||||||
@require_scopes("notes:read", "notes:write")
|
@require_scopes("notes.read", "notes.write")
|
||||||
async def test_function():
|
async def test_function():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Check that metadata was stored
|
# Check that metadata was stored
|
||||||
assert hasattr(test_function, "_required_scopes")
|
assert hasattr(test_function, "_required_scopes")
|
||||||
assert test_function._required_scopes == ["notes:read", "notes:write"]
|
assert test_function._required_scopes == ["notes.read", "notes.write"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@@ -308,28 +308,28 @@ async def test_scope_classification():
|
|||||||
from scripts.add_scope_decorators_simple import classify_function
|
from scripts.add_scope_decorators_simple import classify_function
|
||||||
|
|
||||||
# Test read operations
|
# Test read operations
|
||||||
assert classify_function("nc_notes_get_note") == "notes:read"
|
assert classify_function("nc_notes_get_note") == "notes.read"
|
||||||
assert classify_function("nc_notes_search_notes") == "notes:read"
|
assert classify_function("nc_notes_search_notes") == "notes.read"
|
||||||
assert classify_function("nc_calendar_list_events") == "calendar:read"
|
assert classify_function("nc_calendar_list_events") == "calendar.read"
|
||||||
assert classify_function("nc_webdav_read_file") == "files:read"
|
assert classify_function("nc_webdav_read_file") == "files.read"
|
||||||
assert classify_function("nc_calendar_find_availability") == "calendar:read"
|
assert classify_function("nc_calendar_find_availability") == "calendar.read"
|
||||||
assert classify_function("nc_calendar_get_upcoming_events") == "notes:read"
|
assert classify_function("nc_calendar_get_upcoming_events") == "notes.read"
|
||||||
|
|
||||||
# Test write operations
|
# Test write operations
|
||||||
assert classify_function("nc_notes_create_note") == "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_update_note") == "notes.write"
|
||||||
assert classify_function("nc_notes_delete_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_notes_append_content") == "notes.write"
|
||||||
assert classify_function("nc_calendar_create_event") == "calendar:write"
|
assert classify_function("nc_calendar_create_event") == "calendar.write"
|
||||||
assert classify_function("nc_calendar_update_event") == "notes:write"
|
assert classify_function("nc_calendar_update_event") == "notes.write"
|
||||||
assert classify_function("nc_calendar_manage_calendar") == "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_write_file") == "files.write"
|
||||||
assert classify_function("nc_webdav_move_resource") == "notes:write"
|
assert classify_function("nc_webdav_move_resource") == "notes.write"
|
||||||
assert classify_function("nc_contacts_create_contact") == "notes:write"
|
assert classify_function("nc_contacts_create_contact") == "notes.write"
|
||||||
assert classify_function("nc_cookbook_import_recipe") == "notes:write"
|
assert classify_function("nc_cookbook_import_recipe") == "notes.write"
|
||||||
assert classify_function("nc_tables_insert_row") == "notes:write"
|
assert classify_function("nc_tables_insert_row") == "notes.write"
|
||||||
assert classify_function("deck_archive_card") == "notes:write"
|
assert classify_function("deck_archive_card") == "notes.write"
|
||||||
assert classify_function("deck_assign_label_to_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")
|
@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
|
# Verify default values
|
||||||
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == (
|
assert captured_env["NEXTCLOUD_OIDC_SCOPES"] == (
|
||||||
"openid profile email "
|
"openid profile email "
|
||||||
"notes:read notes:write "
|
"notes.read notes.write "
|
||||||
"calendar:read calendar:write "
|
"calendar.read calendar.write "
|
||||||
"todo:read todo:write "
|
"todo.read todo.write "
|
||||||
"contacts:read contacts:write "
|
"contacts.read contacts.write "
|
||||||
"cookbook:read cookbook:write "
|
"cookbook.read cookbook.write "
|
||||||
"deck:read deck:write "
|
"deck.read deck.write "
|
||||||
"tables:read tables:write "
|
"tables.read tables.write "
|
||||||
"files:read files:write "
|
"files.read files.write "
|
||||||
"sharing:read sharing:write"
|
"sharing.read sharing.write"
|
||||||
)
|
)
|
||||||
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
|
||||||
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class TestGetUserAccess:
|
|||||||
await temp_storage.store_app_password_with_scopes(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="test-app-pw",
|
app_password="test-app-pw",
|
||||||
scopes=["notes:read", "calendar:write"],
|
scopes=["notes.read", "calendar.write"],
|
||||||
username="alice_nc",
|
username="alice_nc",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ class TestGetUserAccess:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
assert data["provisioned"] 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"
|
assert data["username"] == "alice_nc"
|
||||||
|
|
||||||
async def test_missing_auth_header(self, temp_storage):
|
async def test_missing_auth_header(self, temp_storage):
|
||||||
@@ -146,7 +146,7 @@ class TestUpdateUserScopes:
|
|||||||
await temp_storage.store_app_password_with_scopes(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="test-app-pw",
|
app_password="test-app-pw",
|
||||||
scopes=["notes:read"],
|
scopes=["notes.read"],
|
||||||
username="alice_nc",
|
username="alice_nc",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,19 +156,19 @@ class TestUpdateUserScopes:
|
|||||||
resp = client.patch(
|
resp = client.patch(
|
||||||
"/api/v1/users/alice/scopes",
|
"/api/v1/users/alice/scopes",
|
||||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is True
|
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):
|
async def test_invalid_scopes(self, temp_storage):
|
||||||
"""Returns 400 for invalid scope names."""
|
"""Returns 400 for invalid scope names."""
|
||||||
await temp_storage.store_app_password_with_scopes(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="test-app-pw",
|
app_password="test-app-pw",
|
||||||
scopes=["notes:read"],
|
scopes=["notes.read"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app = create_test_app(temp_storage)
|
app = create_test_app(temp_storage)
|
||||||
@@ -177,7 +177,7 @@ class TestUpdateUserScopes:
|
|||||||
resp = client.patch(
|
resp = client.patch(
|
||||||
"/api/v1/users/alice/scopes",
|
"/api/v1/users/alice/scopes",
|
||||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
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
|
assert resp.status_code == 400
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -192,7 +192,7 @@ class TestUpdateUserScopes:
|
|||||||
resp = client.patch(
|
resp = client.patch(
|
||||||
"/api/v1/users/alice/scopes",
|
"/api/v1/users/alice/scopes",
|
||||||
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
headers={"Authorization": create_basic_auth_header("alice", "pw")},
|
||||||
json={"scopes": ["notes:read"]},
|
json={"scopes": ["notes.read"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
data = resp.json()
|
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(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
scopes=["notes:read", "notes:write"],
|
scopes=["notes.read", "notes.write"],
|
||||||
username="alice_nc",
|
username="alice_nc",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||||
assert data is not None
|
assert data is not None
|
||||||
assert data["app_password"] == "aaaaa-bbbbb-ccccc-ddddd-eeeee"
|
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["username"] == "alice_nc"
|
||||||
assert data["created_at"] is not None
|
assert data["created_at"] is not None
|
||||||
assert data["updated_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(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
app_password="aaaaa-bbbbb-ccccc-ddddd-eeeee",
|
||||||
scopes=["notes:read"],
|
scopes=["notes.read"],
|
||||||
)
|
)
|
||||||
await temp_storage.store_app_password_with_scopes(
|
await temp_storage.store_app_password_with_scopes(
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
app_password="xxxxx-yyyyy-zzzzz-aaaaa-bbbbb",
|
||||||
scopes=["notes:read", "calendar:read"],
|
scopes=["notes.read", "calendar.read"],
|
||||||
username="alice_nc",
|
username="alice_nc",
|
||||||
)
|
)
|
||||||
|
|
||||||
data = await temp_storage.get_app_password_with_scopes("alice")
|
data = await temp_storage.get_app_password_with_scopes("alice")
|
||||||
assert data["app_password"] == "xxxxx-yyyyy-zzzzz-aaaaa-bbbbb"
|
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):
|
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",
|
user_id="alice",
|
||||||
poll_token="secret-poll-token",
|
poll_token="secret-poll-token",
|
||||||
poll_endpoint="https://cloud.example.com/login/v2/poll",
|
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")
|
session = await temp_storage.get_login_flow_session("alice")
|
||||||
assert session is not None
|
assert session is not None
|
||||||
assert session["poll_token"] == "secret-poll-token"
|
assert session["poll_token"] == "secret-poll-token"
|
||||||
assert session["poll_endpoint"] == "https://cloud.example.com/login/v2/poll"
|
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["created_at"] is not None
|
||||||
assert session["expires_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():
|
def test_all_supported_scopes():
|
||||||
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
|
"""Test that ALL_SUPPORTED_SCOPES contains expected scopes."""
|
||||||
assert "notes:read" in ALL_SUPPORTED_SCOPES
|
assert "notes.read" in ALL_SUPPORTED_SCOPES
|
||||||
assert "notes:write" in ALL_SUPPORTED_SCOPES
|
assert "notes.write" in ALL_SUPPORTED_SCOPES
|
||||||
assert "calendar:read" in ALL_SUPPORTED_SCOPES
|
assert "calendar.read" in ALL_SUPPORTED_SCOPES
|
||||||
assert "files:read" in ALL_SUPPORTED_SCOPES
|
assert "files.read" in ALL_SUPPORTED_SCOPES
|
||||||
assert "deck:read" in ALL_SUPPORTED_SCOPES
|
assert "deck.read" in ALL_SUPPORTED_SCOPES
|
||||||
# Scopes should be in pairs (read/write)
|
# Scopes should be in pairs (read/write)
|
||||||
read_scopes = [s for s in ALL_SUPPORTED_SCOPES if s.endswith(":read")]
|
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")]
|
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 = AsyncMock()
|
||||||
mock_storage.get_app_password_with_scopes.return_value = {
|
mock_storage.get_app_password_with_scopes.return_value = {
|
||||||
"app_password": "xxxxx",
|
"app_password": "xxxxx",
|
||||||
"scopes": ["notes:read", "calendar:read"],
|
"scopes": ["notes.read", "calendar.read"],
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"created_at": 1000,
|
"created_at": 1000,
|
||||||
"updated_at": 1000,
|
"updated_at": 1000,
|
||||||
@@ -41,7 +41,7 @@ async def test_get_stored_scopes_with_scopes():
|
|||||||
):
|
):
|
||||||
result = await _get_stored_scopes("alice")
|
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():
|
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():
|
def test_scope_decorator_stores_metadata():
|
||||||
"""Test that @require_scopes decorator stores scope requirements as function 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():
|
async def example_function():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Verify metadata is stored
|
# Verify metadata is stored
|
||||||
assert hasattr(example_function, "_required_scopes")
|
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
|
@pytest.mark.unit
|
||||||
def test_scope_decorator_with_single_scope():
|
def test_scope_decorator_with_single_scope():
|
||||||
"""Test decorator with a single scope requirement."""
|
"""Test decorator with a single scope requirement."""
|
||||||
|
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar.read")
|
||||||
async def example_function():
|
async def example_function():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
assert example_function._required_scopes == ["calendar:read"]
|
assert example_function._required_scopes == ["calendar.read"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -46,18 +46,18 @@ def test_scope_decorator_with_no_scopes():
|
|||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_insufficient_scope_error():
|
def test_insufficient_scope_error():
|
||||||
"""Test InsufficientScopeError exception structure."""
|
"""Test InsufficientScopeError exception structure."""
|
||||||
missing = ["notes:write", "calendar:write"]
|
missing = ["notes.write", "calendar.write"]
|
||||||
error = InsufficientScopeError(missing)
|
error = InsufficientScopeError(missing)
|
||||||
|
|
||||||
assert error.missing_scopes == missing
|
assert error.missing_scopes == missing
|
||||||
assert "notes:write" in str(error)
|
assert "notes.write" in str(error)
|
||||||
assert "calendar:write" in str(error)
|
assert "calendar.write" in str(error)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
def test_insufficient_scope_error_with_custom_message():
|
def test_insufficient_scope_error_with_custom_message():
|
||||||
"""Test InsufficientScopeError with custom message."""
|
"""Test InsufficientScopeError with custom message."""
|
||||||
missing = ["files:write"]
|
missing = ["files.write"]
|
||||||
custom_msg = "You need more permissions"
|
custom_msg = "You need more permissions"
|
||||||
error = InsufficientScopeError(missing, custom_msg)
|
error = InsufficientScopeError(missing, custom_msg)
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ class TestRefreshTokenRotation:
|
|||||||
expires_in,
|
expires_in,
|
||||||
) = await broker._refresh_access_token_with_scopes(
|
) = await broker._refresh_access_token_with_scopes(
|
||||||
refresh_token="old_refresh_token_123",
|
refresh_token="old_refresh_token_123",
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes.read"],
|
||||||
user_id="admin",
|
user_id="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -424,7 +424,7 @@ class TestRefreshTokenRotation:
|
|||||||
):
|
):
|
||||||
await broker._refresh_access_token_with_scopes(
|
await broker._refresh_access_token_with_scopes(
|
||||||
refresh_token="same_refresh_token",
|
refresh_token="same_refresh_token",
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes.read"],
|
||||||
user_id="admin",
|
user_id="admin",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -460,7 +460,7 @@ class TestRefreshTokenRotation:
|
|||||||
):
|
):
|
||||||
await broker._refresh_access_token_with_scopes(
|
await broker._refresh_access_token_with_scopes(
|
||||||
refresh_token="old_token",
|
refresh_token="old_token",
|
||||||
required_scopes=["notes:read"],
|
required_scopes=["notes.read"],
|
||||||
user_id=None, # No user_id
|
user_id=None, # No user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user