diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index 315c0f18..ff1dc5a9 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -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,13 @@ 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. + all_scopes.add("offline_access") + # Get all registered tools try: tools = mcp._tool_manager.list_tools() diff --git a/tests/unit/test_scope_decorator.py b/tests/unit/test_scope_decorator.py index ae03caa6..1e4ce716 100644 --- a/tests/unit/test_scope_decorator.py +++ b/tests/unit/test_scope_decorator.py @@ -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,25 @@ 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)