Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 53 additions & 20 deletions auth/scopes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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)
Comment on lines +164 to +169
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function returns a list conversion of a set, which doesn't guarantee consistent ordering across calls. If scope ordering matters for OAuth or comparison operations elsewhere in the codebase, consider returning sorted(all_scopes) instead to ensure deterministic behavior.

Copilot uses AI. Check for mistakes.


def get_current_scopes():
"""
Returns scopes for currently enabled tools.
Expand All @@ -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):
Expand All @@ -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))

Expand Down
9 changes: 9 additions & 0 deletions auth/service_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
34 changes: 30 additions & 4 deletions core/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Comment on lines +112 to 122
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested conditionals checking if required_scopes: and then if not all(...) can be simplified. Tools without required scopes (required_scopes is empty) will pass the check, but the logic would be clearer if this case was explicitly handled or the comment explained that tools without scope requirements are allowed in read-only mode.

Suggested change
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)
required_scopes = getattr(func_to_check, "_required_google_scopes", [])
if not required_scopes:
# Tools without required scopes are allowed in read-only mode
continue
# 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)

Copilot uses AI. Check for mistakes.
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'}"
)
4 changes: 4 additions & 0 deletions core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 10 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("")

Expand Down Expand Up @@ -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 ''}:"
Expand Down
Loading