diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index ff1dc5a9..d270dbe1 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -541,6 +541,11 @@ def discover_all_scopes(mcp) -> list[str]: # 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 diff --git a/tests/unit/test_scope_decorator.py b/tests/unit/test_scope_decorator.py index 1e4ce716..ebc89f32 100644 --- a/tests/unit/test_scope_decorator.py +++ b/tests/unit/test_scope_decorator.py @@ -87,3 +87,14 @@ def test_discover_all_scopes_always_includes_offline_access(): 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)