From c30a795a1fbbc9fcc13962ef941491441c5267a1 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Thu, 11 Jun 2026 15:30:41 +0200 Subject: [PATCH] feat(auth): advertise offline_access in discovered OAuth scopes discover_all_scopes() builds the scopes_supported lists exposed at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. It previously emitted only the base OIDC scopes plus tool-derived @require_scopes, so offline_access was never advertised and discovery-driven MCP clients had no way to know they could request a refresh token. Add offline_access unconditionally. The AS proxy already forwards client-requested scopes upstream to Nextcloud, which issues a refresh token when the MCP server's OIDC client is permitted the scope. This only changes what is advertised; it is not added to ALL_SUPPORTED_SCOPES (the app-level permission set), since offline_access is an OIDC behavior rather than a Nextcloud resource permission. Add a regression test asserting offline_access is always present in discover_all_scopes() output. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../auth/scope_authorization.py | 10 +++++++- tests/unit/test_scope_decorator.py | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) 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)