docs: Parse available scopes from registered tools and update docs

This commit is contained in:
Chris Coutinho
2025-10-25 21:16:40 +02:00
parent 90b96a8afe
commit 415b1c901b
5 changed files with 233 additions and 36 deletions
+22 -25
View File
@@ -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",
+2
View File
@@ -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",
@@ -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)