From f2d2dd80684f451f4b9e783401727506de8e44cd Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 23 Oct 2025 15:51:27 +0200 Subject: [PATCH] feat: Enable token introspection for opaque tokens --- docs/jwt-oauth-reference.md | 16 +++++++++------- nextcloud_mcp_server/app.py | 29 +++++++++++++++-------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/docs/jwt-oauth-reference.md b/docs/jwt-oauth-reference.md index 44cfc57..d0266fb 100644 --- a/docs/jwt-oauth-reference.md +++ b/docs/jwt-oauth-reference.md @@ -169,25 +169,27 @@ async def nc_notes_create_note(title: str, content: str, ctx: Context): ### Dynamic Tool Filtering -The MCP server implements **dynamic tool filtering** - users only see tools they have permission to use: +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: -**JWT with `nc:read` only:** +**Token with `nc:read` only:** - `list_tools()` returns 36 read-only tools - Write tools are hidden from the tool list -**JWT with `nc:write` only:** +**Token with `nc:write` only:** - `list_tools()` returns 54 write-only tools - Read tools are hidden from the tool list -**JWT with both scopes:** +**Token with both scopes:** - `list_tools()` returns all 90 tools -**JWT with no custom scopes:** +**Token with no custom scopes:** - `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`) **BasicAuth mode:** - `list_tools()` returns all 90 tools (no filtering) +**Note:** JWT tokens include scopes in the token payload, while Bearer tokens retrieve scopes via the introspection endpoint. Both methods provide reliable scope information for filtering. + ### Scope Challenges When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header: @@ -456,9 +458,9 @@ When credentials are provided via environment variables or storage file, **DCR i - `has_required_scopes()` - Check if user has necessary scopes - `InsufficientScopeError` exception for WWW-Authenticate challenges -**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:433-488`) +**3. Dynamic Filtering** (`nextcloud_mcp_server/app.py:473-516`) - Overrides FastMCP's `list_tools()` method -- Filters based on user's JWT token scopes +- Filters based on user's OAuth token scopes (JWT and Bearer) - Only active in OAuth mode - Bypassed in BasicAuth mode diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 3bb8f24..3ca39c1 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -214,7 +214,7 @@ async def load_oauth_client_credentials( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, storage_path=storage_path, - client_name="Nextcloud MCP Server", + client_name=f"Nextcloud MCP Server ({token_type})", redirect_uris=redirect_uris, scopes=scopes, token_type=token_type, @@ -475,7 +475,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): original_list_tools = mcp._tool_manager.list_tools def list_tools_filtered(): - """List tools filtered by user's token scopes (JWT tokens only).""" + """List tools filtered by user's token scopes (JWT and Bearer tokens).""" # Get user's scopes from token using MCP SDK's contextvar # This works for all request types including list_tools user_scopes = get_access_token_scopes() @@ -488,35 +488,36 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): # Get all tools all_tools = original_list_tools() - # Only filter for JWT tokens (opaque tokens show all tools) - # JWT tokens have scopes embedded, so we can reliably filter - # Opaque tokens may not have accurate scope information from introspection - if is_jwt and user_scopes: + # Filter tools based on user's token scopes (both JWT and opaque tokens) + # JWT tokens have scopes embedded in payload + # Opaque tokens get scopes via introspection endpoint + # Claude Code now properly respects PRM endpoint for scope discovery + if user_scopes: allowed_tools = [ tool for tool in all_tools if has_required_scopes(tool.fn, user_scopes) ] + token_type = "JWT" if is_jwt else "Bearer" logger.info( - f"✂️ JWT scope filtering: {len(allowed_tools)}/{len(all_tools)} tools " + f"✂️ {token_type} scope filtering: {len(allowed_tools)}/{len(all_tools)} tools " f"available for scopes: {user_scopes}" ) else: - # Opaque token, BasicAuth mode, or no token - show all tools + # BasicAuth mode or no token - show all tools allowed_tools = all_tools - reason = ( - "opaque token (no filtering)" - if not is_jwt and user_scopes - else "no token/BasicAuth" + logger.info( + f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)" ) - logger.info(f"📋 Showing all {len(all_tools)} tools ({reason})") # Return the Tool objects directly (they're already in the correct format) return allowed_tools # Replace the tool manager's list_tools method mcp._tool_manager.list_tools = list_tools_filtered - logger.info("Dynamic tool filtering enabled for OAuth mode (JWT tokens only)") + logger.info( + "Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)" + ) if transport == "sse": mcp_app = mcp.sse_app()