From 2c0b764aae9635c285ead99d4d5432d92813cce2 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 8 Apr 2026 01:27:06 +0200 Subject: [PATCH] fix: strip resource server prefix from JWT scopes for tool filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External IdPs like AWS Cognito return scopes prefixed with the resource server identifier (e.g. https://mcp.example.com/notes.read). MCP tools use bare scope names (notes.read) in @require_scopes decorators. Without stripping the prefix, scope matching fails and only identity-only tools (openid/profile/email) are visible — resulting in 4/125 tools shown. Strip the OIDC_RESOURCE_SERVER_ID prefix in both get_access_token_scopes() (used by list_tools filtering) and the require_scopes decorator (used at tool execution time). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../auth/scope_authorization.py | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py index 4f9d5f40..1a5e7a15 100644 --- a/nextcloud_mcp_server/auth/scope_authorization.py +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -184,8 +184,8 @@ def require_scopes(*required_scopes: str): ) return await func(*args, **kwargs) - # Extract scopes from access token - token_scopes = set(access_token.scopes or []) + # Extract scopes from access token (strip resource prefix if configured) + token_scopes = _strip_resource_prefix(set(access_token.scopes or [])) required_scopes_set = set(required_scopes) # Check if offline access is enabled @@ -263,6 +263,33 @@ def require_scopes(*required_scopes: str): return decorator +def _strip_resource_prefix(scopes: set[str]) -> set[str]: + """Strip resource server URL prefix from scopes. + + External IdPs like AWS Cognito return scopes prefixed with the resource + server identifier (e.g. ``https://mcp.example.com/notes.read``). MCP + tools use bare scope names (``notes.read``), so we strip the prefix to + allow matching. + + The prefix is read from ``OIDC_RESOURCE_SERVER_ID`` (set in settings). + Standard OIDC scopes (openid, profile, email, offline_access) are never + prefixed and are passed through unchanged. + """ + settings = get_settings() + resource_server_id = getattr(settings, "oidc_resource_server_id", None) + if not resource_server_id: + return scopes + + prefix = resource_server_id.rstrip("/") + "/" + stripped: set[str] = set() + for scope in scopes: + if scope.startswith(prefix): + stripped.add(scope[len(prefix) :]) + else: + stripped.add(scope) + return stripped + + def get_access_token_scopes(ctx: Context | None = None) -> set[str]: """ Extract scopes from the authenticated user's access token. @@ -270,6 +297,10 @@ def get_access_token_scopes(ctx: Context | None = None) -> set[str]: This function uses MCP SDK's contextvar to access the token, which works across all request types including list_tools. + If ``OIDC_RESOURCE_SERVER_ID`` is configured, resource-prefixed scopes + (e.g. ``https://mcp.example.com/notes.read``) are stripped to bare names + (``notes.read``) so they match tool ``@require_scopes`` decorators. + Args: ctx: FastMCP context object (unused, kept for compatibility) @@ -285,6 +316,7 @@ def get_access_token_scopes(ctx: Context | None = None) -> set[str]: return set() scopes = set(access_token.scopes or []) + scopes = _strip_resource_prefix(scopes) logger.info(f"✅ Extracted scopes from access token: {scopes}") return scopes