diff --git a/README.md b/README.md index 214a854..8776e8f 100644 --- a/README.md +++ b/README.md @@ -186,18 +186,20 @@ The server exposes Nextcloud functionality through MCP tools (for actions) and r The server provides 90+ tools across 8 Nextcloud apps. When using OAuth, tools are dynamically filtered based on your granted scopes. +For a complete list of all supported OAuth scopes and their descriptions, see [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes). + #### Available Tool Categories | App | Tools | Read Scope | Write Scope | Operations | |-----|-------|-----------|-------------|------------| -| **Notes** | 7 | `mcp:notes:read` | `mcp:notes:write` | Create, read, update, delete, search notes | -| **Calendar** | 20+ | `mcp:calendar:read` | `mcp:calendar:write` | Events, todos (tasks), calendars, recurring events, attendees | -| **Contacts** | 8 | `mcp:contacts:read` | `mcp:contacts:write` | Create, read, update, delete contacts and address books | -| **Files (WebDAV)** | 12 | `mcp:files:read` | `mcp:files:write` | List, read, upload, delete, move files; **OCR/document processing** | -| **Deck** | 15 | `mcp:deck:read` | `mcp:deck:write` | Boards, stacks, cards, labels, assignments | -| **Cookbook** | 13 | `mcp:cookbook:read` | `mcp:cookbook:write` | Recipes, import from URLs, search, categories | -| **Tables** | 5 | `mcp:tables:read` | `mcp:tables:write` | Row operations on Nextcloud Tables | -| **Sharing** | 10+ | `mcp:sharing:read` | `mcp:sharing:write` | Create, manage, delete shares | +| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes | +| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees | +| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books | +| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** | +| **Deck** | 15 | `deck:read` | `deck:write` | Boards, stacks, cards, labels, assignments | +| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories | +| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables | +| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares | #### Document Processing (Optional) @@ -257,7 +259,7 @@ See [env.sample](env.sample) for complete configuration options. - And 80+ more... > [!TIP] -> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `mcp:notes:read` and `mcp:notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools. +> **OAuth Scope Filtering**: When connecting via OAuth, MCP clients will only see tools for which you've granted access. For example, granting only `notes:read` and `notes:write` will show 7 Notes tools instead of all 90+ tools. See [OAuth Scopes Documentation](docs/oauth-architecture.md#oauth-scopes) for the complete scope reference, or [OAuth Troubleshooting - Limited Scopes](docs/oauth-troubleshooting.md#limited-scopes---only-seeing-notes-tools) if you're only seeing a subset of tools. > > **Known Issue**: Claude Code and some other MCP clients may only request/grant Notes scopes during initial connection. Track progress at [#234](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/234). diff --git a/docs/oauth-architecture.md b/docs/oauth-architecture.md index 4d44406..3a1cf21 100644 --- a/docs/oauth-architecture.md +++ b/docs/oauth-architecture.md @@ -272,14 +272,145 @@ client = get_client_from_context(ctx) - Protects against authorization code interception ### Scopes -- Required scopes: `openid`, `profile` -- Additional scopes inferred from userinfo response +- Base required scopes: `openid`, `profile`, `email` +- App-specific scopes control access to individual Nextcloud apps +- See [OAuth Scopes](#oauth-scopes) section for complete scope reference ### Token Validation - Every MCP request validates Bearer token - Cached for performance (1-hour default) - Calls userinfo endpoint for validation +## OAuth Scopes + +The Nextcloud MCP Server implements fine-grained OAuth scopes for each Nextcloud app integration. Scopes control which tools are visible and accessible to users based on their granted permissions. + +### Scope-Based Access Control + +When using OAuth authentication: +1. **Dynamic Discovery**: The server automatically discovers all required scopes from `@require_scopes` decorators on MCP tools +2. **Tool Filtering**: Tools are dynamically filtered based on the user's token scopes - users only see tools they have permission to use +3. **Per-Tool Enforcement**: Each tool validates required scopes before execution, returning a 403 error if insufficient scopes are present + +### Supported Scopes + +The server supports the following OAuth scopes, organized by Nextcloud app: + +#### Base OIDC Scopes +- `openid` - OpenID Connect authentication (required) +- `profile` - Access to user profile information (required) +- `email` - Access to user email address (required) + +#### Notes App +- `notes:read` - Read notes, search notes, get note attachments +- `notes:write` - Create, update, append to, and delete notes + +#### Calendar App +- `calendar:read` - List calendars, read events, search events +- `calendar:write` - Create, update, and delete calendars and events + +#### Calendar Tasks (VTODO) +- `todo:read` - List and read CalDAV tasks +- `todo:write` - Create, update, and delete CalDAV tasks + +#### Contacts App +- `contacts:read` - List address books and read contacts (CardDAV) +- `contacts:write` - Create, update, and delete address books and contacts + +#### Cookbook App +- `cookbook:read` - Read recipes, search recipes +- `cookbook:write` - Create, update, and delete recipes + +#### Deck App +- `deck:read` - List boards, stacks, cards, and labels +- `deck:write` - Create, update, and delete boards, stacks, cards, and labels + +#### Tables App +- `tables:read` - List tables and read rows +- `tables:write` - Create, update, and delete rows in tables + +#### Files (WebDAV) +- `files:read` - List files, read file contents, search files +- `files:write` - Upload, update, move, copy, and delete files + +#### Sharing +- `sharing:read` - List shares and read share information +- `sharing:write` - Create, update, and delete shares + +### Scope Discovery + +The MCP server provides scope discovery through two mechanisms: + +#### 1. Protected Resource Metadata (PRM) Endpoint +```bash +# Query the PRM endpoint +curl http://localhost:8000/.well-known/oauth-protected-resource/mcp + +# Response includes dynamically discovered scopes +{ + "resource": "http://localhost:8000/mcp", + "scopes_supported": ["openid", "profile", "email", "notes:read", ...], + "authorization_servers": ["https://nextcloud.example.com"], + "bearer_methods_supported": ["header"], + "resource_signing_alg_values_supported": ["RS256"] +} +``` + +The `scopes_supported` field is **dynamically generated** from all registered MCP tools, ensuring it always reflects the actual available scopes. + +#### 2. Scope Enforcement via Decorators + +Tools are decorated with `@require_scopes()` to declare their required permissions: + +```python +from nextcloud_mcp_server.auth import require_scopes + +@mcp.tool() +@require_scopes("notes:read") +async def nc_notes_get_note(ctx: Context, note_id: int): + """Get a specific note by ID""" + # Implementation +``` + +### Client Registration Scopes + +During OAuth client registration (dynamic or manual), clients request a set of scopes that define the **maximum allowed** scopes for that client. The actual per-tool enforcement is handled separately via decorators. + +**Environment Variable**: +```bash +NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write calendar:read calendar:write ..." +``` + +**Default**: All supported scopes (recommended for development) + +> **Note**: Client registration scopes define the maximum permissions. The MCP server's PRM endpoint dynamically advertises the actual supported scopes based on registered tools. + +### Step-Up Authorization + +The server supports OAuth step-up authorization (RFC 8693). If a user attempts to use a tool requiring scopes they don't have: + +1. Tool returns `403 Forbidden` with `InsufficientScopeError` +2. Response includes `WWW-Authenticate` header listing missing scopes: + ``` + WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata="..." + ``` +3. Client can re-authorize with additional scopes + +### Scope Validation + +All scope enforcement happens at two levels: + +1. **Tool Visibility**: During `list_tools` requests, only tools matching the user's token scopes are returned +2. **Execution Time**: When calling a tool, the `@require_scopes` decorator validates the token has necessary scopes + +**Example**: +```python +# User token has: ["openid", "profile", "email", "notes:read"] +# They will see: 4 read-only notes tools +# They will NOT see: 3 write notes tools (notes:write required) +# Attempting to call a write tool returns 403 Forbidden +``` + ## Configuration See [Configuration Guide](configuration.md) for all OAuth environment variables: diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 0bb61d3..4a19bb8 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -18,6 +18,7 @@ from starlette.routing import Mount, Route from nextcloud_mcp_server.auth import ( InsufficientScopeError, NextcloudTokenVerifier, + discover_all_scopes, get_access_token_scopes, has_required_scopes, is_jwt_token, @@ -283,7 +284,15 @@ async def load_oauth_client_credentials( redirect_uris = [f"{mcp_server_url}/oauth/callback"] # Get scopes from environment or use defaults - # Default: all app-specific read/write scopes + # Note: Client registration happens BEFORE tools are registered, so we can't + # dynamically discover scopes here. These scopes define the "maximum allowed" + # scopes for this OAuth client. The actual per-tool scope enforcement happens + # via @require_scopes decorators, and the PRM endpoint advertises the actual + # supported scopes dynamically. + # + # IMPORTANT: Keep this list in sync with all @require_scopes decorators + # when adding new apps, or set NEXTCLOUD_OIDC_SCOPES environment variable + # to override. default_scopes = ( "openid profile email " "notes:read notes:write " @@ -644,7 +653,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): if oauth_enabled: def oauth_protected_resource_metadata(request): - """RFC 9728 Protected Resource Metadata endpoint.""" + """RFC 9728 Protected Resource Metadata endpoint. + + Dynamically discovers supported scopes from registered MCP tools. + This ensures the advertised scopes always match the actual tool requirements. + """ mcp_server_url = os.getenv( "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" ) @@ -658,30 +671,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): # Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set public_issuer_url = os.getenv("NEXTCLOUD_HOST", "") + # Dynamically discover all scopes from registered tools + # This provides a single source of truth based on @require_scopes decorators + supported_scopes = discover_all_scopes(mcp) + return JSONResponse( { "resource": resource_url, - "scopes_supported": [ - "openid", - "notes:read", - "notes:write", - "calendar:read", - "calendar:write", - "todo:read", - "todo:write", - "contacts:read", - "contacts:write", - "cookbook:read", - "cookbook:write", - "deck:read", - "deck:write", - "tables:read", - "tables:write", - "files:read", - "files:write", - "sharing:read", - "sharing:write", - ], + "scopes_supported": supported_scopes, "authorization_servers": [public_issuer_url], "bearer_methods_supported": ["header"], "resource_signing_alg_values_supported": ["RS256"], @@ -832,9 +829,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): @click.option( "--oauth-scopes", envvar="NEXTCLOUD_OIDC_SCOPES", - default="openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write", + default="openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write", show_default=True, - help="OAuth scopes to request (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)", ) @click.option( "--oauth-token-type", diff --git a/nextcloud_mcp_server/auth/__init__.py b/nextcloud_mcp_server/auth/__init__.py index b8580bd..2a973af 100644 --- a/nextcloud_mcp_server/auth/__init__.py +++ b/nextcloud_mcp_server/auth/__init__.py @@ -7,6 +7,7 @@ from .scope_authorization import ( InsufficientScopeError, ScopeAuthorizationError, check_scopes, + discover_all_scopes, get_access_token_scopes, get_required_scopes, has_required_scopes, @@ -25,6 +26,7 @@ __all__ = [ "ScopeAuthorizationError", "InsufficientScopeError", "check_scopes", + "discover_all_scopes", "get_access_token_scopes", "get_required_scopes", "has_required_scopes", diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index e32ac57..cbaafc4 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -276,3 +276,68 @@ def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool: # Check if user has all required scopes return set(required).issubset(user_scopes) + + +def discover_all_scopes(mcp) -> list[str]: + """ + Dynamically discover all OAuth scopes required by registered MCP tools. + + This function inspects all registered tools and extracts their required scopes + from the @require_scopes decorator metadata. It provides a single source of truth + for available scopes based on the actual tool implementations. + + Args: + mcp: FastMCP instance with registered tools + + Returns: + Sorted list of unique scope strings, including base OIDC scopes + + Example: + ```python + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("My Server") + + @mcp.tool() + @require_scopes("notes:read") + async def get_notes(): + pass + + @mcp.tool() + @require_scopes("notes:write") + async def create_note(): + pass + + scopes = discover_all_scopes(mcp) + # Returns: ["notes:read", "notes:write", "openid", "profile", "email"] + ``` + + Note: + - Base OIDC scopes (openid, profile, email) are always included + - Scopes are deduplicated and sorted alphabetically + - Only scopes from decorated tools are included + - Must be called after tools are registered + """ + # Start with base OIDC scopes that are always required + all_scopes = {"openid", "profile", "email"} + + # Get all registered tools + try: + tools = mcp._tool_manager.list_tools() + except AttributeError: + logger.warning("FastMCP instance does not have _tool_manager attribute") + return sorted(all_scopes) + + # Extract scopes from each tool + for tool in tools: + # Get the original function (tools have a .fn attribute) + func = getattr(tool, "fn", None) + if func is None: + continue + + # Extract scopes using existing helper + tool_scopes = get_required_scopes(func) + all_scopes.update(tool_scopes) + + # Return sorted list of unique scopes + return sorted(all_scopes)