diff --git a/README.md b/README.md index feb632ee..6eec8a35 100644 --- a/README.md +++ b/README.md @@ -522,6 +522,13 @@ uv run main.py --tools sheets docs uv run main.py --single-user --tools gmail ``` + +**🔒 Read-Only Mode** +```bash +# Requests only read-only scopes & disables write tools +uv run main.py --read-only +``` + **★ Tool Tiers** ```bash uv run main.py --tool-tier core # ● Essential tools only diff --git a/auth/scopes.py b/auth/scopes.py index b68bf1c7..ddff4034 100644 --- a/auth/scopes.py +++ b/auth/scopes.py @@ -113,6 +113,20 @@ "search": CUSTOM_SEARCH_SCOPES, } +# Tool-to-read-only-scopes mapping +TOOL_READONLY_SCOPES_MAP = { + "gmail": [GMAIL_READONLY_SCOPE], + "drive": [DRIVE_READONLY_SCOPE], + "calendar": [CALENDAR_READONLY_SCOPE], + "docs": [DOCS_READONLY_SCOPE], + "sheets": [SHEETS_READONLY_SCOPE], + "chat": [CHAT_READONLY_SCOPE], + "forms": [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE], + "slides": [SLIDES_READONLY_SCOPE], + "tasks": [TASKS_READONLY_SCOPE], + "search": CUSTOM_SEARCH_SCOPES, +} + def set_enabled_tools(enabled_tools): """ @@ -126,6 +140,35 @@ def set_enabled_tools(enabled_tools): logger.info(f"Enabled tools set for scope management: {enabled_tools}") +# Global variable to store read-only mode (set by main.py) +_READ_ONLY_MODE = False + + +def set_read_only(enabled: bool): + """ + Set the global read-only mode. + + Args: + enabled: Boolean indicating if read-only mode should be enabled. + """ + global _READ_ONLY_MODE + _READ_ONLY_MODE = enabled + logger.info(f"Read-only mode set to: {enabled}") + + +def is_read_only_mode() -> bool: + """Check if read-only mode is enabled.""" + return _READ_ONLY_MODE + + +def get_all_read_only_scopes() -> list[str]: + """Get all possible read-only scopes across all tools.""" + all_scopes = set(BASE_SCOPES) + for scopes in TOOL_READONLY_SCOPES_MAP.values(): + all_scopes.update(scopes) + return list(all_scopes) + + def get_current_scopes(): """ Returns scopes for currently enabled tools. @@ -134,24 +177,7 @@ def get_current_scopes(): Returns: List of unique scopes for the enabled tools plus base scopes. """ - enabled_tools = _ENABLED_TOOLS - if enabled_tools is None: - # Default behavior - return all scopes - enabled_tools = TOOL_SCOPES_MAP.keys() - - # Start with base scopes (always required) - scopes = BASE_SCOPES.copy() - - # Add scopes for each enabled tool - for tool in enabled_tools: - if tool in TOOL_SCOPES_MAP: - scopes.extend(TOOL_SCOPES_MAP[tool]) - - logger.debug( - f"Generated scopes for tools {list(enabled_tools)}: {len(set(scopes))} unique scopes" - ) - # Return unique scopes - return list(set(scopes)) + return get_scopes_for_tools(_ENABLED_TOOLS) def get_scopes_for_tools(enabled_tools=None): @@ -171,11 +197,18 @@ def get_scopes_for_tools(enabled_tools=None): # Start with base scopes (always required) scopes = BASE_SCOPES.copy() + # Determine which map to use based on read-only mode + scope_map = TOOL_READONLY_SCOPES_MAP if _READ_ONLY_MODE else TOOL_SCOPES_MAP + mode_str = "read-only" if _READ_ONLY_MODE else "full" + # Add scopes for each enabled tool for tool in enabled_tools: - if tool in TOOL_SCOPES_MAP: - scopes.extend(TOOL_SCOPES_MAP[tool]) + if tool in scope_map: + scopes.extend(scope_map[tool]) + logger.debug( + f"Generated {mode_str} scopes for tools {list(enabled_tools)}: {len(set(scopes))} unique scopes" + ) # Return unique scopes return list(set(scopes)) diff --git a/auth/service_decorator.py b/auth/service_decorator.py index f2f37318..3615b0f4 100644 --- a/auth/service_decorator.py +++ b/auth/service_decorator.py @@ -636,6 +636,9 @@ async def wrapper(*args, **kwargs): if func.__doc__: wrapper.__doc__ = _remove_user_email_arg_from_docstring(func.__doc__) + # Attach required scopes to the wrapper for tool filtering + wrapper._required_google_scopes = _resolve_scopes(scopes) + return wrapper return decorator @@ -774,6 +777,12 @@ async def wrapper(*args, **kwargs): if func.__doc__: wrapper.__doc__ = _remove_user_email_arg_from_docstring(func.__doc__) + # Attach all required scopes to the wrapper for tool filtering + all_scopes = [] + for config in service_configs: + all_scopes.extend(_resolve_scopes(config["scopes"])) + wrapper._required_google_scopes = all_scopes + return wrapper return decorator diff --git a/core/tool_registry.py b/core/tool_registry.py index d965ae1c..c39e5a6d 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -90,16 +90,42 @@ def filter_server_tools(server): if hasattr(tool_manager, "_tools"): tool_registry = tool_manager._tools + from auth.scopes import is_read_only_mode, get_all_read_only_scopes + + read_only_mode = is_read_only_mode() + allowed_scopes = set(get_all_read_only_scopes()) if read_only_mode else None + tools_to_remove = [] - for tool_name in list(tool_registry.keys()): + for tool_name, tool_func in tool_registry.items(): + # 1. Tier filtering if not is_tool_enabled(tool_name): tools_to_remove.append(tool_name) + continue + + # 2. Read-only filtering + if read_only_mode: + # Check if tool has required scopes attached (from @require_google_service) + # Note: FastMCP wraps functions in Tool objects, so we need to check .fn if available + func_to_check = tool_func + if hasattr(tool_func, "fn"): + func_to_check = tool_func.fn + + required_scopes = getattr(func_to_check, "_required_google_scopes", []) + + if required_scopes: + # If ANY required scope is not in the allowed read-only scopes, disable the tool + if not all(scope in allowed_scopes for scope in required_scopes): + logger.info( + f"Read-only mode: Disabling tool '{tool_name}' (requires write scopes: {required_scopes})" + ) + tools_to_remove.append(tool_name) for tool_name in tools_to_remove: - del tool_registry[tool_name] - tools_removed += 1 + if tool_name in tool_registry: + del tool_registry[tool_name] + tools_removed += 1 if tools_removed > 0: logger.info( - f"Tool tier filtering: removed {tools_removed} tools, {len(enabled_tools)} enabled" + f"Tool filtering: removed {tools_removed} tools. Mode: {'Read-Only' if is_read_only_mode() else 'Full'}" ) diff --git a/core/utils.py b/core/utils.py index 056f17b7..b3abb046 100644 --- a/core/utils.py +++ b/core/utils.py @@ -336,6 +336,10 @@ async def wrapper(*args, **kwargs): logger.exception(message) raise Exception(message) from e + # Propagate _required_google_scopes if present (for tool filtering) + if hasattr(func, "_required_google_scopes"): + wrapper._required_google_scopes = func._required_google_scopes + return wrapper return decorator diff --git a/main.py b/main.py index 8f0de848..121368d5 100644 --- a/main.py +++ b/main.py @@ -118,6 +118,11 @@ def main(): default="stdio", help="Transport mode: stdio (default) or streamable-http", ) + parser.add_argument( + "--read-only", + action="store_true", + help="Run in read-only mode - requests only read-only scopes and disables tools requiring write permissions", + ) args = parser.parse_args() # Set port and base URI once for reuse throughout the function @@ -139,6 +144,8 @@ def main(): safe_print(f" 🔗 URL: {display_url}") safe_print(f" 🔐 OAuth Callback: {display_url}/oauth2callback") safe_print(f" 👤 Mode: {'Single-user' if args.single_user else 'Multi-user'}") + if args.read_only: + safe_print(" 🔒 Read-Only: Enabled") safe_print(f" 🐍 Python: {sys.version.split()[0]}") safe_print("") @@ -231,9 +238,11 @@ def main(): wrap_server_tool_method(server) - from auth.scopes import set_enabled_tools + from auth.scopes import set_enabled_tools, set_read_only set_enabled_tools(list(tools_to_import)) + if args.read_only: + set_read_only(True) safe_print( f"🛠️ Loading {len(tools_to_import)} tool module{'s' if len(tools_to_import) != 1 else ''}:"