Merge pull request #894 from cbcoutinho/feat/advertise-offline-access-scope

feat(auth): advertise offline_access in discovered OAuth scopes
This commit is contained in:
Chris Coutinho
2026-06-11 15:46:21 +02:00
committed by GitHub
2 changed files with 49 additions and 1 deletions
@@ -523,11 +523,12 @@ def discover_all_scopes(mcp) -> list[str]:
pass
scopes = discover_all_scopes(mcp)
# Returns: ["notes.read", "notes.write", "openid", "profile", "email"]
# Returns: ["notes.read", "notes.write", "offline_access", "openid", ...]
```
Note:
- Base OIDC scopes (openid, profile, email) are always included
- offline_access is always included so clients can request a refresh token
- Scopes are deduplicated and sorted alphabetically
- Only scopes from decorated tools are included
- Must be called after tools are registered
@@ -535,6 +536,18 @@ def discover_all_scopes(mcp) -> list[str]:
# Start with base OIDC scopes that are always required
all_scopes = {"openid", "profile", "email"}
# Advertise offline_access so discovery-driven MCP clients can request a
# refresh token. The AS proxy forwards it upstream to Nextcloud, which
# issues a refresh token when the MCP server's OIDC client is permitted the
# scope. Optional for clients (unlike the base OIDC scopes) and never tied
# to a tool, so it is added here rather than discovered from @require_scopes.
#
# Advertised unconditionally — independent of settings.enable_offline_access
# (which gates the server's own Flow 2 background access). Per RFC 8414,
# scopes_supported lists what the AS *can* support, not what it will always
# grant; the actual refresh token is still gated upstream by Nextcloud.
all_scopes.add("offline_access")
# Get all registered tools
try:
tools = mcp._tool_manager.list_tools()
+35
View File
@@ -1,9 +1,11 @@
"""Unit tests for scope decorator metadata and classification logic."""
import pytest
from mcp.server.fastmcp import FastMCP
from nextcloud_mcp_server.auth.scope_authorization import (
InsufficientScopeError,
discover_all_scopes,
require_scopes,
)
@@ -63,3 +65,36 @@ def test_insufficient_scope_error_with_custom_message():
assert error.missing_scopes == missing
assert str(error) == custom_msg
@pytest.mark.unit
def test_discover_all_scopes_always_includes_offline_access():
"""offline_access is advertised so discovery-driven clients can request a refresh token.
It is not tied to any tool's @require_scopes, so it must be present even on
an MCP instance with a single unrelated tool. Guards the metadata exposed at
/.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.
"""
mcp = FastMCP(name="test-scope-discovery")
@mcp.tool()
@require_scopes("notes.read")
async def example_tool():
pass
scopes = discover_all_scopes(mcp)
assert "offline_access" in scopes
# Base OIDC scopes and tool-derived scopes still come through.
assert {"openid", "profile", "email", "notes.read"}.issubset(scopes)
@pytest.mark.unit
def test_discover_all_scopes_offline_access_without_any_tools():
"""The offline_access invariant must not depend on any tool being registered."""
mcp = FastMCP(name="empty")
scopes = discover_all_scopes(mcp)
assert "offline_access" in scopes
assert {"openid", "profile", "email"}.issubset(scopes)