feat: Enable token introspection for opaque tokens
This commit is contained in:
@@ -169,25 +169,27 @@ async def nc_notes_create_note(title: str, content: str, ctx: Context):
|
|||||||
|
|
||||||
### Dynamic Tool Filtering
|
### 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
|
- `list_tools()` returns 36 read-only tools
|
||||||
- Write tools are hidden from the tool list
|
- 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
|
- `list_tools()` returns 54 write-only tools
|
||||||
- Read tools are hidden from the tool list
|
- Read tools are hidden from the tool list
|
||||||
|
|
||||||
**JWT with both scopes:**
|
**Token with both scopes:**
|
||||||
- `list_tools()` returns all 90 tools
|
- `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`)
|
- `list_tools()` returns 0 tools (all require `nc:read` or `nc:write`)
|
||||||
|
|
||||||
**BasicAuth mode:**
|
**BasicAuth mode:**
|
||||||
- `list_tools()` returns all 90 tools (no filtering)
|
- `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
|
### Scope Challenges
|
||||||
|
|
||||||
When a tool is called without required scopes, the server returns a `403 Forbidden` response with a `WWW-Authenticate` header:
|
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
|
- `has_required_scopes()` - Check if user has necessary scopes
|
||||||
- `InsufficientScopeError` exception for WWW-Authenticate challenges
|
- `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
|
- 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
|
- Only active in OAuth mode
|
||||||
- Bypassed in BasicAuth mode
|
- Bypassed in BasicAuth mode
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -214,7 +214,7 @@ async def load_oauth_client_credentials(
|
|||||||
nextcloud_url=nextcloud_host,
|
nextcloud_url=nextcloud_host,
|
||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
storage_path=storage_path,
|
storage_path=storage_path,
|
||||||
client_name="Nextcloud MCP Server",
|
client_name=f"Nextcloud MCP Server ({token_type})",
|
||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
token_type=token_type,
|
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
|
original_list_tools = mcp._tool_manager.list_tools
|
||||||
|
|
||||||
def list_tools_filtered():
|
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
|
# Get user's scopes from token using MCP SDK's contextvar
|
||||||
# This works for all request types including list_tools
|
# This works for all request types including list_tools
|
||||||
user_scopes = get_access_token_scopes()
|
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
|
# Get all tools
|
||||||
all_tools = original_list_tools()
|
all_tools = original_list_tools()
|
||||||
|
|
||||||
# Only filter for JWT tokens (opaque tokens show all tools)
|
# Filter tools based on user's token scopes (both JWT and opaque tokens)
|
||||||
# JWT tokens have scopes embedded, so we can reliably filter
|
# JWT tokens have scopes embedded in payload
|
||||||
# Opaque tokens may not have accurate scope information from introspection
|
# Opaque tokens get scopes via introspection endpoint
|
||||||
if is_jwt and user_scopes:
|
# Claude Code now properly respects PRM endpoint for scope discovery
|
||||||
|
if user_scopes:
|
||||||
allowed_tools = [
|
allowed_tools = [
|
||||||
tool
|
tool
|
||||||
for tool in all_tools
|
for tool in all_tools
|
||||||
if has_required_scopes(tool.fn, user_scopes)
|
if has_required_scopes(tool.fn, user_scopes)
|
||||||
]
|
]
|
||||||
|
token_type = "JWT" if is_jwt else "Bearer"
|
||||||
logger.info(
|
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}"
|
f"available for scopes: {user_scopes}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Opaque token, BasicAuth mode, or no token - show all tools
|
# BasicAuth mode or no token - show all tools
|
||||||
allowed_tools = all_tools
|
allowed_tools = all_tools
|
||||||
reason = (
|
logger.info(
|
||||||
"opaque token (no filtering)"
|
f"📋 Showing all {len(all_tools)} tools (no token/BasicAuth)"
|
||||||
if not is_jwt and user_scopes
|
|
||||||
else "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 the Tool objects directly (they're already in the correct format)
|
||||||
return allowed_tools
|
return allowed_tools
|
||||||
|
|
||||||
# Replace the tool manager's list_tools method
|
# Replace the tool manager's list_tools method
|
||||||
mcp._tool_manager.list_tools = list_tools_filtered
|
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":
|
if transport == "sse":
|
||||||
mcp_app = mcp.sse_app()
|
mcp_app = mcp.sse_app()
|
||||||
|
|||||||
Reference in New Issue
Block a user