From aa520b72d39772c61eeec7eab8ccd9d03e536eda Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Tue, 24 Feb 2026 21:09:14 -0400 Subject: [PATCH 01/21] fix all them tests --- auth/google_auth.py | 27 +++++++++++++++++ auth/scopes.py | 18 ++++++++++++ core/tool_registry.py | 46 ++++++++++++++++++++++++++--- main.py | 54 +++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- tests/gchat/test_chat_tools.py | 2 +- uv.lock | 2 +- 7 files changed, 143 insertions(+), 8 deletions(-) diff --git a/auth/google_auth.py b/auth/google_auth.py index 6b16d82..9c452ff 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -482,6 +482,12 @@ def handle_auth_callback( ) os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + # Allow partial scope grants without raising an exception. + # When users decline some scopes on Google's consent screen, + # oauthlib raises because the granted scopes differ from requested. + if "OAUTHLIB_RELAX_TOKEN_SCOPE" not in os.environ: + os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + store = get_oauth21_session_store() parsed_response = urlparse(authorization_response) state_values = parse_qs(parsed_response.query).get("state") @@ -504,6 +510,27 @@ def handle_auth_callback( credentials = flow.credentials logger.info("Successfully exchanged authorization code for tokens.") + # Handle partial OAuth grants: if the user declined some scopes on + # Google's consent screen, credentials.granted_scopes contains only + # what was actually authorized. Store those instead of the inflated + # requested scopes so that refresh() sends the correct scope set. + granted = getattr(credentials, "granted_scopes", None) + if granted and set(granted) != set(credentials.scopes or []): + logger.warning( + "Partial OAuth grant detected. Requested: %s, Granted: %s", + credentials.scopes, + granted, + ) + credentials = Credentials( + token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=list(granted), + expiry=credentials.expiry, + ) + # Get user info to determine user_id (using email here) user_info = get_user_info(credentials) if not user_info or "email" not in user_info: diff --git a/auth/scopes.py b/auth/scopes.py index 5895189..aa610ac 100644 --- a/auth/scopes.py +++ b/auth/scopes.py @@ -291,6 +291,24 @@ def get_scopes_for_tools(enabled_tools=None): Returns: List of unique scopes for the enabled tools plus base scopes. """ + # Granular permissions mode overrides both full and read-only scope maps. + # Lazy import with guard to avoid circular dependency during module init + # (SCOPES = get_scopes_for_tools() runs at import time before auth.permissions + # is fully loaded, but permissions mode is never active at that point). + try: + from auth.permissions import is_permissions_mode, get_all_permission_scopes + + if is_permissions_mode(): + scopes = BASE_SCOPES.copy() + scopes.extend(get_all_permission_scopes()) + logger.debug( + "Generated scopes from granular permissions: %d unique scopes", + len(set(scopes)), + ) + return list(set(scopes)) + except ImportError: + pass + if enabled_tools is None: # Default behavior - return all scopes enabled_tools = TOOL_SCOPES_MAP.keys() diff --git a/core/tool_registry.py b/core/tool_registry.py index b14dc5d..206d561 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -9,6 +9,7 @@ import logging from typing import Set, Optional, Callable from auth.oauth_config import is_oauth21_enabled +from auth.permissions import is_permissions_mode, get_allowed_scopes_set from auth.scopes import is_read_only_mode, get_all_read_only_scopes logger = logging.getLogger(__name__) @@ -104,7 +105,13 @@ def filter_server_tools(server): """Remove disabled tools from the server after registration.""" enabled_tools = get_enabled_tools() oauth21_enabled = is_oauth21_enabled() - if enabled_tools is None and not oauth21_enabled and not is_read_only_mode(): + permissions_mode = is_permissions_mode() + if ( + enabled_tools is None + and not oauth21_enabled + and not is_read_only_mode() + and not permissions_mode + ): return tools_removed = 0 @@ -126,8 +133,8 @@ def filter_server_tools(server): tools_to_remove.add("start_google_auth") logger.info("OAuth 2.1 enabled: disabling start_google_auth tool") - # 3. Read-only mode filtering - if read_only_mode: + # 3. Read-only mode filtering (skipped when granular permissions are active) + if read_only_mode and not permissions_mode: for tool_name, tool_obj in tool_components.items(): if tool_name in tools_to_remove: continue @@ -147,6 +154,32 @@ def filter_server_tools(server): ) tools_to_remove.add(tool_name) + # 4. Granular permissions filtering + # No scope hierarchy expansion here β€” permission levels are already cumulative + # and explicitly define allowed scopes. Hierarchy expansion would defeat the + # purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the + # "organize" permission level intentionally excludes gmail.send). + if permissions_mode: + perm_allowed = get_allowed_scopes_set() or set() + + for tool_name, tool_obj in tool_components.items(): + if tool_name in tools_to_remove: + continue + + func_to_check = tool_obj + if hasattr(tool_obj, "fn"): + func_to_check = tool_obj.fn + + required_scopes = getattr(func_to_check, "_required_google_scopes", []) + if required_scopes: + if not all(scope in perm_allowed for scope in required_scopes): + logger.info( + "Permissions mode: Disabling tool '%s' (requires: %s)", + tool_name, + required_scopes, + ) + tools_to_remove.add(tool_name) + for tool_name in tools_to_remove: try: server.local_provider.remove_tool(tool_name) @@ -167,7 +200,12 @@ def filter_server_tools(server): if tools_removed > 0: enabled_count = len(enabled_tools) if enabled_tools is not None else "all" - mode = "Read-Only" if is_read_only_mode() else "Full" + if permissions_mode: + mode = "Permissions" + elif is_read_only_mode(): + mode = "Read-Only" + else: + mode = "Full" logger.info( f"Tool filtering: removed {tools_removed} tools, {enabled_count} enabled. Mode: {mode}" ) diff --git a/main.py b/main.py index 64cc465..5f67467 100644 --- a/main.py +++ b/main.py @@ -155,6 +155,18 @@ def main(): action="store_true", help="Run in read-only mode - requests only read-only scopes and disables tools requiring write permissions", ) + parser.add_argument( + "--permissions", + nargs="+", + metavar="SERVICE:LEVEL", + help=( + "Granular per-service permission levels. Format: service:level. " + "Example: --permissions gmail:organize drive:readonly. " + "Gmail levels: readonly, organize, drafts, send, full (cumulative). " + "Other services: readonly, full. " + "Mutually exclusive with --read-only." + ), + ) args = parser.parse_args() # Clean up CLI args - argparse.REMAINDER may include leading dashes from first arg @@ -162,6 +174,14 @@ def main(): # Filter out empty strings that might appear args.cli = [a for a in args.cli if a] + # Validate mutually exclusive flags + if args.permissions and args.read_only: + safe_print( + "Error: --permissions and --read-only are mutually exclusive. " + "Use service:readonly within --permissions instead." + ) + sys.exit(1) + # Set port and base URI once for reuse throughout the function port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) base_uri = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") @@ -184,6 +204,8 @@ def main(): safe_print(f" πŸ‘€ Mode: {'Single-user' if args.single_user else 'Multi-user'}") if args.read_only: safe_print(" πŸ”’ Read-Only: Enabled") + if args.permissions: + safe_print(" πŸ”’ Permissions: Granular mode") safe_print(f" 🐍 Python: {sys.version.split()[0]}") safe_print("") @@ -265,7 +287,32 @@ def main(): } # Determine which tools to import based on arguments - if args.tool_tier is not None: + perms = None + if args.permissions: + # Granular permissions mode β€” parse and activate before tool selection + from auth.permissions import parse_permissions_arg, set_permissions + + try: + perms = parse_permissions_arg(args.permissions) + except ValueError as e: + safe_print(f"❌ {e}") + sys.exit(1) + set_permissions(perms) + # Permissions implicitly defines which services to load + tools_to_import = list(perms.keys()) + set_enabled_tool_names(None) + + if args.tool_tier is not None: + # Combine with tier filtering within the permission-selected services + try: + tier_tools, _ = resolve_tools_from_tier( + args.tool_tier, tools_to_import + ) + set_enabled_tool_names(set(tier_tools)) + except Exception as e: + safe_print(f"❌ Error loading tools for tier '{args.tool_tier}': {e}") + sys.exit(1) + elif args.tool_tier is not None: # Use tier-based tool selection, optionally filtered by services try: tier_tools, suggested_services = resolve_tools_from_tier( @@ -314,6 +361,11 @@ def main(): except ModuleNotFoundError as exc: logger.error("Failed to import tool '%s': %s", tool, exc, exc_info=True) safe_print(f" ⚠️ Failed to load {tool.title()} tool module ({exc}).") + + if perms: + safe_print("πŸ”’ Permission Levels:") + for svc, lvl in sorted(perms.items()): + safe_print(f" {tool_icons.get(svc, ' ')} {svc}: {lvl}") safe_print("") # Filter tools based on tier configuration (if tier-based loading is enabled) diff --git a/pyproject.toml b/pyproject.toml index b4fb5ac..8ff15e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,7 +108,7 @@ where = ["."] exclude = ["tests*", "docs*", "build", "dist"] [tool.pytest.ini_options] -collect_ignore_glob = ["**/manual_test.py"] +addopts = "--ignore=tests/gappsscript/manual_test.py" [tool.setuptools.package-data] core = ["tool_tiers.yaml"] diff --git a/tests/gchat/test_chat_tools.py b/tests/gchat/test_chat_tools.py index dac3515..1fb4dc1 100644 --- a/tests/gchat/test_chat_tools.py +++ b/tests/gchat/test_chat_tools.py @@ -43,7 +43,7 @@ def _make_attachment( def _unwrap(tool): """Unwrap a FunctionTool + decorator chain to the original async function.""" - fn = tool.fn # FunctionTool stores the wrapped callable in .fn + fn = getattr(tool, "fn", tool) while hasattr(fn, "__wrapped__"): fn = fn.__wrapped__ return fn diff --git a/uv.lock b/uv.lock index 1da1ac2..7842e03 100644 --- a/uv.lock +++ b/uv.lock @@ -2035,7 +2035,7 @@ wheels = [ [[package]] name = "workspace-mcp" -version = "1.12.0" +version = "1.13.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From cfc68d86059c94f549f7b366938354574bda12fa Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Tue, 24 Feb 2026 21:28:50 -0400 Subject: [PATCH 02/21] implement --permissions flag --- auth/permissions.py | 242 ++++++++++++++++++++++++++++++++++++++++++++ main.py | 5 +- 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 auth/permissions.py diff --git a/auth/permissions.py b/auth/permissions.py new file mode 100644 index 0000000..52415ae --- /dev/null +++ b/auth/permissions.py @@ -0,0 +1,242 @@ +""" +Granular per-service permission levels. + +Each service has named permission levels (cumulative), mapping to a list of +OAuth scopes. The levels for a service are ordered from least to most +permissive β€” requesting level N implicitly includes all scopes from levels < N. + +Usage: + --permissions gmail:organize drive:readonly + +Gmail levels: readonly, organize, drafts, send, full +Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS) +""" + +import logging +from typing import Dict, List, Optional, Tuple + +from auth.scopes import ( + GMAIL_READONLY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_SETTINGS_BASIC_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_FILE_SCOPE, + DRIVE_SCOPE, + CALENDAR_READONLY_SCOPE, + CALENDAR_EVENTS_SCOPE, + CALENDAR_SCOPE, + DOCS_READONLY_SCOPE, + DOCS_WRITE_SCOPE, + SHEETS_READONLY_SCOPE, + SHEETS_WRITE_SCOPE, + CHAT_READONLY_SCOPE, + CHAT_WRITE_SCOPE, + CHAT_SPACES_SCOPE, + CHAT_SPACES_READONLY_SCOPE, + FORMS_BODY_SCOPE, + FORMS_BODY_READONLY_SCOPE, + FORMS_RESPONSES_READONLY_SCOPE, + SLIDES_SCOPE, + SLIDES_READONLY_SCOPE, + TASKS_SCOPE, + TASKS_READONLY_SCOPE, + CONTACTS_SCOPE, + CONTACTS_READONLY_SCOPE, + CUSTOM_SEARCH_SCOPE, + SCRIPT_PROJECTS_SCOPE, + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, +) + +logger = logging.getLogger(__name__) + +# Ordered permission levels per service. +# Each entry is (level_name, [additional_scopes_at_this_level]). +# Scopes are CUMULATIVE: level N includes all scopes from levels 0..N. +SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { + "gmail": [ + ("readonly", [GMAIL_READONLY_SCOPE]), + ("organize", [GMAIL_LABELS_SCOPE, GMAIL_MODIFY_SCOPE]), + ("drafts", [GMAIL_COMPOSE_SCOPE]), + ("send", [GMAIL_SEND_SCOPE]), + ("full", [GMAIL_SETTINGS_BASIC_SCOPE]), + ], + "drive": [ + ("readonly", [DRIVE_READONLY_SCOPE]), + ("full", [DRIVE_SCOPE, DRIVE_FILE_SCOPE]), + ], + "calendar": [ + ("readonly", [CALENDAR_READONLY_SCOPE]), + ("full", [CALENDAR_SCOPE, CALENDAR_EVENTS_SCOPE]), + ], + "docs": [ + ("readonly", [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), + ("full", [DOCS_WRITE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE]), + ], + "sheets": [ + ("readonly", [SHEETS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), + ("full", [SHEETS_WRITE_SCOPE, DRIVE_READONLY_SCOPE]), + ], + "chat": [ + ("readonly", [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE]), + ("full", [CHAT_WRITE_SCOPE, CHAT_SPACES_SCOPE]), + ], + "forms": [ + ("readonly", [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), + ("full", [FORMS_BODY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), + ], + "slides": [ + ("readonly", [SLIDES_READONLY_SCOPE]), + ("full", [SLIDES_SCOPE]), + ], + "tasks": [ + ("readonly", [TASKS_READONLY_SCOPE]), + ("full", [TASKS_SCOPE]), + ], + "contacts": [ + ("readonly", [CONTACTS_READONLY_SCOPE]), + ("full", [CONTACTS_SCOPE]), + ], + "search": [ + ("readonly", [CUSTOM_SEARCH_SCOPE]), + ("full", [CUSTOM_SEARCH_SCOPE]), + ], + "appscript": [ + ("readonly", [ + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_READONLY_SCOPE, + ]), + ("full", [ + SCRIPT_PROJECTS_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_FILE_SCOPE, + ]), + ], +} + +# Module-level state: parsed --permissions config +# Dict mapping service_name -> level_name, e.g. {"gmail": "organize"} +_PERMISSIONS: Optional[Dict[str, str]] = None + + +def set_permissions(permissions: Dict[str, str]) -> None: + """Set granular permissions from parsed --permissions argument.""" + global _PERMISSIONS + _PERMISSIONS = permissions + logger.info("Granular permissions set: %s", permissions) + + +def get_permissions() -> Optional[Dict[str, str]]: + """Return current permissions dict, or None if not using granular mode.""" + return _PERMISSIONS + + +def is_permissions_mode() -> bool: + """Check if granular permissions mode is active.""" + return _PERMISSIONS is not None + + +def get_scopes_for_permission(service: str, level: str) -> List[str]: + """ + Get cumulative scopes for a service at a given permission level. + + Returns all scopes up to and including the named level. + Raises ValueError if service or level is unknown. + """ + levels = SERVICE_PERMISSION_LEVELS.get(service) + if levels is None: + raise ValueError(f"Unknown service: '{service}'") + + cumulative: List[str] = [] + found = False + for level_name, level_scopes in levels: + cumulative.extend(level_scopes) + if level_name == level: + found = True + break + + if not found: + valid = [name for name, _ in levels] + raise ValueError( + f"Unknown permission level '{level}' for service '{service}'. " + f"Valid levels: {valid}" + ) + + return list(set(cumulative)) + + +def get_all_permission_scopes() -> List[str]: + """ + Get the combined scopes for all services at their configured permission levels. + + Only meaningful when is_permissions_mode() is True. + """ + if _PERMISSIONS is None: + return [] + + all_scopes: set = set() + for service, level in _PERMISSIONS.items(): + all_scopes.update(get_scopes_for_permission(service, level)) + return list(all_scopes) + + +def get_allowed_scopes_set() -> Optional[set]: + """ + Get the set of allowed scopes under permissions mode (for tool filtering). + + Returns None if permissions mode is not active. + """ + if _PERMISSIONS is None: + return None + return set(get_all_permission_scopes()) + + +def get_valid_levels(service: str) -> List[str]: + """Get valid permission level names for a service.""" + levels = SERVICE_PERMISSION_LEVELS.get(service) + if levels is None: + return [] + return [name for name, _ in levels] + + +def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]: + """ + Parse --permissions arguments like ["gmail:organize", "drive:full"]. + + Returns dict mapping service -> level. + Raises ValueError on parse errors (unknown service, invalid level, bad format). + """ + result: Dict[str, str] = {} + for entry in permissions_list: + if ":" not in entry: + raise ValueError( + f"Invalid permission format: '{entry}'. " + f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')" + ) + service, level = entry.split(":", 1) + if service in result: + raise ValueError(f"Duplicate service in permissions: '{service}'") + if service not in SERVICE_PERMISSION_LEVELS: + raise ValueError( + f"Unknown service: '{service}'. " + f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}" + ) + valid = get_valid_levels(service) + if level not in valid: + raise ValueError( + f"Unknown level '{level}' for service '{service}'. " + f"Valid levels: {valid}" + ) + result[service] = level + return result diff --git a/main.py b/main.py index 5f67467..a10625c 100644 --- a/main.py +++ b/main.py @@ -176,9 +176,10 @@ def main(): # Validate mutually exclusive flags if args.permissions and args.read_only: - safe_print( + print( "Error: --permissions and --read-only are mutually exclusive. " - "Use service:readonly within --permissions instead." + "Use service:readonly within --permissions instead.", + file=sys.stderr, ) sys.exit(1) From e81d0e367fec48e4b93413973b190290da5107a7 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Wed, 25 Feb 2026 09:03:48 -0400 Subject: [PATCH 03/21] no safe_print for exit error messages --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index a10625c..6f7ff26 100644 --- a/main.py +++ b/main.py @@ -296,7 +296,7 @@ def main(): try: perms = parse_permissions_arg(args.permissions) except ValueError as e: - safe_print(f"❌ {e}") + print(f"Error: {e}", file=sys.stderr) sys.exit(1) set_permissions(perms) # Permissions implicitly defines which services to load From 86a8e1be4dd6d840b19edd5efee6db178352bac0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 23:44:33 +0000 Subject: [PATCH 04/21] style: auto-fix ruff lint and format --- auth/permissions.py | 34 ++++++++++++++++++++-------------- main.py | 4 +--- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/auth/permissions.py b/auth/permissions.py index 52415ae..98de857 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -108,20 +108,26 @@ SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { ("full", [CUSTOM_SEARCH_SCOPE]), ], "appscript": [ - ("readonly", [ - SCRIPT_PROJECTS_READONLY_SCOPE, - SCRIPT_DEPLOYMENTS_READONLY_SCOPE, - SCRIPT_PROCESSES_READONLY_SCOPE, - SCRIPT_METRICS_SCOPE, - DRIVE_READONLY_SCOPE, - ]), - ("full", [ - SCRIPT_PROJECTS_SCOPE, - SCRIPT_DEPLOYMENTS_SCOPE, - SCRIPT_PROCESSES_READONLY_SCOPE, - SCRIPT_METRICS_SCOPE, - DRIVE_FILE_SCOPE, - ]), + ( + "readonly", + [ + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_READONLY_SCOPE, + ], + ), + ( + "full", + [ + SCRIPT_PROJECTS_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_FILE_SCOPE, + ], + ), ], } diff --git a/main.py b/main.py index 6f7ff26..784b8aa 100644 --- a/main.py +++ b/main.py @@ -306,9 +306,7 @@ def main(): if args.tool_tier is not None: # Combine with tier filtering within the permission-selected services try: - tier_tools, _ = resolve_tools_from_tier( - args.tool_tier, tools_to_import - ) + tier_tools, _ = resolve_tools_from_tier(args.tool_tier, tools_to_import) set_enabled_tool_names(set(tier_tools)) except Exception as e: safe_print(f"❌ Error loading tools for tier '{args.tool_tier}': {e}") From 117c6af88b7e6da04298667b79ce2769c4fe8c89 Mon Sep 17 00:00:00 2001 From: Francisco Date: Thu, 26 Feb 2026 12:46:51 +1300 Subject: [PATCH 05/21] Adding nextPageToken for pagination in drive tools --- gdrive/drive_helpers.py | 5 + gdrive/drive_tools.py | 24 +++- tests/gdrive/test_drive_tools.py | 189 ++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 7 deletions(-) diff --git a/gdrive/drive_helpers.py b/gdrive/drive_helpers.py index 26426c6..9985427 100644 --- a/gdrive/drive_helpers.py +++ b/gdrive/drive_helpers.py @@ -181,6 +181,7 @@ def build_drive_list_params( drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, + page_token: Optional[str] = None, ) -> Dict[str, Any]: """ Helper function to build common list parameters for Drive API calls. @@ -191,6 +192,7 @@ def build_drive_list_params( drive_id: Optional shared drive ID include_items_from_all_drives: Whether to include items from all drives corpora: Optional corpus specification + page_token: Optional page token for pagination (from a previous nextPageToken) Returns: Dictionary of parameters for Drive API list calls @@ -203,6 +205,9 @@ def build_drive_list_params( "includeItemsFromAllDrives": include_items_from_all_drives, } + if page_token: + list_params["pageToken"] = page_token + if drive_id: list_params["driveId"] = drive_id if corpora: diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index 2e6ec6a..f7cfa9f 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -57,6 +57,7 @@ async def search_drive_files( user_google_email: str, query: str, page_size: int = 10, + page_token: Optional[str] = None, drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, @@ -68,6 +69,7 @@ async def search_drive_files( user_google_email (str): The user's Google email address. Required. query (str): The search query string. Supports Google Drive search operators. page_size (int): The maximum number of files to return. Defaults to 10. + page_token (Optional[str]): Page token from a previous response's nextPageToken to retrieve the next page of results. drive_id (Optional[str]): ID of the shared drive to search. If None, behavior depends on `corpora` and `include_items_from_all_drives`. include_items_from_all_drives (bool): Whether shared drive items should be included in results. Defaults to True. This is effective when not specifying a `drive_id`. corpora (Optional[str]): Bodies of items to query (e.g., 'user', 'domain', 'drive', 'allDrives'). @@ -76,6 +78,7 @@ async def search_drive_files( Returns: str: A formatted list of found files/folders with their details (ID, name, type, size, modified time, link). + Includes a nextPageToken line when more results are available. """ logger.info( f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}'" @@ -104,6 +107,7 @@ async def search_drive_files( drive_id=drive_id, include_items_from_all_drives=include_items_from_all_drives, corpora=corpora, + page_token=page_token, ) results = await asyncio.to_thread(service.files().list(**list_params).execute) @@ -111,9 +115,11 @@ async def search_drive_files( if not files: return f"No files found for '{query}'." - formatted_files_text_parts = [ - f"Found {len(files)} files for {user_google_email} matching '{query}':" - ] + next_token = results.get("nextPageToken") + header = f"Found {len(files)} files for {user_google_email} matching '{query}':" + if next_token: + header += f"\nnextPageToken: {next_token}" + formatted_files_text_parts = [header] for item in files: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_files_text_parts.append( @@ -411,6 +417,7 @@ async def list_drive_items( user_google_email: str, folder_id: str = "root", page_size: int = 100, + page_token: Optional[str] = None, drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, @@ -424,12 +431,14 @@ async def list_drive_items( user_google_email (str): The user's Google email address. Required. folder_id (str): The ID of the Google Drive folder. Defaults to 'root'. For a shared drive, this can be the shared drive's ID to list its root, or a folder ID within that shared drive. page_size (int): The maximum number of items to return. Defaults to 100. + page_token (Optional[str]): Page token from a previous response's nextPageToken to retrieve the next page of results. drive_id (Optional[str]): ID of the shared drive. If provided, the listing is scoped to this drive. include_items_from_all_drives (bool): Whether items from all accessible shared drives should be included if `drive_id` is not set. Defaults to True. corpora (Optional[str]): Corpus to query ('user', 'drive', 'allDrives'). If `drive_id` is set and `corpora` is None, 'drive' is used. If None and no `drive_id`, API defaults apply. Returns: str: A formatted list of files/folders in the specified folder. + Includes a nextPageToken line when more results are available. """ logger.info( f"[list_drive_items] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'" @@ -444,6 +453,7 @@ async def list_drive_items( drive_id=drive_id, include_items_from_all_drives=include_items_from_all_drives, corpora=corpora, + page_token=page_token, ) results = await asyncio.to_thread(service.files().list(**list_params).execute) @@ -451,9 +461,11 @@ async def list_drive_items( if not files: return f"No items found in folder '{folder_id}'." - formatted_items_text_parts = [ - f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" - ] + next_token = results.get("nextPageToken") + header = f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" + if next_token: + header += f"\nnextPageToken: {next_token}" + formatted_items_text_parts = [header] for item in files: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_items_text_parts.append( diff --git a/tests/gdrive/test_drive_tools.py b/tests/gdrive/test_drive_tools.py index e6eaa2f..e8c607a 100644 --- a/tests/gdrive/test_drive_tools.py +++ b/tests/gdrive/test_drive_tools.py @@ -1,7 +1,7 @@ """ Unit tests for Google Drive MCP tools. -Tests create_drive_folder with mocked API responses. +Tests create_drive_folder, search_drive_files, and list_drive_items with mocked API responses. """ import pytest @@ -11,6 +11,193 @@ import os sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +from gdrive.drive_tools import list_drive_items, search_drive_files + + +def _unwrap(fn): + """Unwrap a decorator chain to the original async function.""" + if hasattr(fn, "fn"): + fn = fn.fn # FunctionTool wrapper (other server versions) + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +# --------------------------------------------------------------------------- +# search_drive_files β€” page_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_drive_files_page_token_passed_to_api(): + """page_token is forwarded to the Drive API as pageToken.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f1", + "name": "Report.pdf", + "mimeType": "application/pdf", + "webViewLink": "https://drive.google.com/file/f1", + "modifiedTime": "2024-01-01T00:00:00Z", + } + ] + } + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="budget", + page_token="tok_abc123", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert call_kwargs.get("pageToken") == "tok_abc123" + + +@pytest.mark.asyncio +async def test_search_drive_files_next_page_token_in_output(): + """nextPageToken from the API response is appended to the output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f2", + "name": "Notes.docx", + "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "webViewLink": "https://drive.google.com/file/f2", + "modifiedTime": "2024-02-01T00:00:00Z", + } + ], + "nextPageToken": "next_tok_xyz", + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="notes", + ) + + assert "nextPageToken: next_tok_xyz" in result + + +@pytest.mark.asyncio +async def test_search_drive_files_no_next_page_token_when_absent(): + """nextPageToken does not appear in output when the API has no more pages.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f3", + "name": "Summary.txt", + "mimeType": "text/plain", + "webViewLink": "https://drive.google.com/file/f3", + "modifiedTime": "2024-03-01T00:00:00Z", + } + ] + # no nextPageToken key + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="summary", + ) + + assert "nextPageToken" not in result + + +# --------------------------------------------------------------------------- +# list_drive_items β€” page_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_page_token_passed_to_api(mock_resolve_folder): + """page_token is forwarded to the Drive API as pageToken.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "folder1", + "name": "Archive", + "mimeType": "application/vnd.google-apps.folder", + "webViewLink": "https://drive.google.com/drive/folders/folder1", + "modifiedTime": "2024-01-15T00:00:00Z", + } + ] + } + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + page_token="tok_page2", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert call_kwargs.get("pageToken") == "tok_page2" + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder): + """nextPageToken from the API response is appended to the output.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "file99", + "name": "data.csv", + "mimeType": "text/csv", + "webViewLink": "https://drive.google.com/file/file99", + "modifiedTime": "2024-04-01T00:00:00Z", + } + ], + "nextPageToken": "next_list_tok", + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + ) + + assert "nextPageToken: next_list_tok" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_no_next_page_token_when_absent(mock_resolve_folder): + """nextPageToken does not appear in output when the API has no more pages.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "file100", + "name": "readme.txt", + "mimeType": "text/plain", + "webViewLink": "https://drive.google.com/file/file100", + "modifiedTime": "2024-05-01T00:00:00Z", + } + ] + # no nextPageToken key + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + ) + + assert "nextPageToken" not in result + + +# --------------------------------------------------------------------------- +# create_drive_folder +# --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_create_drive_folder(): From 351afa4331e9e22a4a65aacd4742bc00c56f031d Mon Sep 17 00:00:00 2001 From: Francisco Date: Thu, 26 Feb 2026 22:53:40 +1300 Subject: [PATCH 06/21] Optional hidden details in drive search and list --- gdrive/drive_helpers.py | 9 +- gdrive/drive_tools.py | 34 ++- tests/gdrive/test_drive_tools.py | 365 ++++++++++++++++++++++++++++++- 3 files changed, 397 insertions(+), 11 deletions(-) diff --git a/gdrive/drive_helpers.py b/gdrive/drive_helpers.py index 26426c6..f0d8be4 100644 --- a/gdrive/drive_helpers.py +++ b/gdrive/drive_helpers.py @@ -181,6 +181,7 @@ def build_drive_list_params( drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, + detailed: bool = True, ) -> Dict[str, Any]: """ Helper function to build common list parameters for Drive API calls. @@ -191,14 +192,20 @@ def build_drive_list_params( drive_id: Optional shared drive ID include_items_from_all_drives: Whether to include items from all drives corpora: Optional corpus specification + detailed: Whether to request size, modifiedTime, and webViewLink fields. + Defaults to True to preserve existing behavior. Returns: Dictionary of parameters for Drive API list calls """ + if detailed: + fields = "nextPageToken, files(id, name, mimeType, webViewLink, iconLink, modifiedTime, size)" + else: + fields = "nextPageToken, files(id, name, mimeType)" list_params = { "q": query, "pageSize": page_size, - "fields": "nextPageToken, files(id, name, mimeType, webViewLink, iconLink, modifiedTime, size)", + "fields": fields, "supportsAllDrives": True, "includeItemsFromAllDrives": include_items_from_all_drives, } diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index 2e6ec6a..2cfc01d 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -60,6 +60,7 @@ async def search_drive_files( drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, + detailed: bool = True, ) -> str: """ Searches for files and folders within a user's Google Drive, including shared drives. @@ -73,9 +74,10 @@ async def search_drive_files( corpora (Optional[str]): Bodies of items to query (e.g., 'user', 'domain', 'drive', 'allDrives'). If 'drive_id' is specified and 'corpora' is None, it defaults to 'drive'. Otherwise, Drive API default behavior applies. Prefer 'user' or 'drive' over 'allDrives' for efficiency. + detailed (bool): Whether to include size, modified time, and link in results. Defaults to True. Returns: - str: A formatted list of found files/folders with their details (ID, name, type, size, modified time, link). + str: A formatted list of found files/folders with their details (ID, name, type, and optionally size, modified time, link). """ logger.info( f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}'" @@ -104,6 +106,7 @@ async def search_drive_files( drive_id=drive_id, include_items_from_all_drives=include_items_from_all_drives, corpora=corpora, + detailed=detailed, ) results = await asyncio.to_thread(service.files().list(**list_params).execute) @@ -115,10 +118,15 @@ async def search_drive_files( f"Found {len(files)} files for {user_google_email} matching '{query}':" ] for item in files: - size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" - formatted_files_text_parts.append( - f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' - ) + if detailed: + size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" + formatted_files_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' + ) + else: + formatted_files_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' + ) text_output = "\n".join(formatted_files_text_parts) return text_output @@ -414,6 +422,7 @@ async def list_drive_items( drive_id: Optional[str] = None, include_items_from_all_drives: bool = True, corpora: Optional[str] = None, + detailed: bool = True, ) -> str: """ Lists files and folders, supporting shared drives. @@ -427,6 +436,7 @@ async def list_drive_items( drive_id (Optional[str]): ID of the shared drive. If provided, the listing is scoped to this drive. include_items_from_all_drives (bool): Whether items from all accessible shared drives should be included if `drive_id` is not set. Defaults to True. corpora (Optional[str]): Corpus to query ('user', 'drive', 'allDrives'). If `drive_id` is set and `corpora` is None, 'drive' is used. If None and no `drive_id`, API defaults apply. + detailed (bool): Whether to include size, modified time, and link in results. Defaults to True. Returns: str: A formatted list of files/folders in the specified folder. @@ -444,6 +454,7 @@ async def list_drive_items( drive_id=drive_id, include_items_from_all_drives=include_items_from_all_drives, corpora=corpora, + detailed=detailed, ) results = await asyncio.to_thread(service.files().list(**list_params).execute) @@ -455,10 +466,15 @@ async def list_drive_items( f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" ] for item in files: - size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" - formatted_items_text_parts.append( - f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' - ) + if detailed: + size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" + formatted_items_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' + ) + else: + formatted_items_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' + ) text_output = "\n".join(formatted_items_text_parts) return text_output diff --git a/tests/gdrive/test_drive_tools.py b/tests/gdrive/test_drive_tools.py index e6eaa2f..3c6fb20 100644 --- a/tests/gdrive/test_drive_tools.py +++ b/tests/gdrive/test_drive_tools.py @@ -1,7 +1,9 @@ """ Unit tests for Google Drive MCP tools. -Tests create_drive_folder with mocked API responses. +Tests create_drive_folder with mocked API responses, and the `detailed` +parameter added to search_drive_files, list_drive_items, and +build_drive_list_params. """ import pytest @@ -11,6 +13,51 @@ import os sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) +from gdrive.drive_helpers import build_drive_list_params +from gdrive.drive_tools import list_drive_items, search_drive_files + + +def _unwrap(tool): + """Unwrap a FunctionTool + decorator chain to the original async function. + + Handles both older FastMCP (FunctionTool with .fn) and newer FastMCP + (server.tool() returns the function directly). + """ + fn = tool.fn if hasattr(tool, "fn") else tool + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_file( + file_id: str, + name: str, + mime_type: str, + link: str = "http://link", + modified: str = "2024-01-01T00:00:00Z", + size: str | None = None, +) -> dict: + item = { + "id": file_id, + "name": name, + "mimeType": mime_type, + "webViewLink": link, + "modifiedTime": modified, + } + if size is not None: + item["size"] = size + return item + + +# --------------------------------------------------------------------------- +# create_drive_folder +# --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_create_drive_folder(): @@ -44,3 +91,319 @@ async def test_create_drive_folder(): assert "folder123" in result assert "user@example.com" in result assert "https://drive.google.com/drive/folders/folder123" in result + + +# --------------------------------------------------------------------------- +# build_drive_list_params β€” detailed flag (pure unit tests, no I/O) +# --------------------------------------------------------------------------- + + +def test_build_params_detailed_true_includes_extra_fields(): + """detailed=True requests modifiedTime, webViewLink, and size from the API.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=True) + assert "modifiedTime" in params["fields"] + assert "webViewLink" in params["fields"] + assert "size" in params["fields"] + + +def test_build_params_detailed_false_omits_extra_fields(): + """detailed=False omits modifiedTime, webViewLink, and size from the API request.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=False) + assert "modifiedTime" not in params["fields"] + assert "webViewLink" not in params["fields"] + assert "size" not in params["fields"] + + +def test_build_params_detailed_false_keeps_core_fields(): + """detailed=False still requests id, name, and mimeType.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=False) + assert "id" in params["fields"] + assert "name" in params["fields"] + assert "mimeType" in params["fields"] + + +def test_build_params_default_is_detailed(): + """Omitting detailed behaves identically to detailed=True.""" + params_default = build_drive_list_params(query="q", page_size=5) + params_true = build_drive_list_params(query="q", page_size=5, detailed=True) + assert params_default["fields"] == params_true["fields"] + + +# --------------------------------------------------------------------------- +# search_drive_files β€” detailed flag +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_detailed_true_output_includes_metadata(): + """detailed=True (default) includes modified time and link in output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "f1", + "My Doc", + "application/vnd.google-apps.document", + modified="2024-06-01T12:00:00Z", + link="http://link/f1", + ) + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my doc", + detailed=True, + ) + + assert "My Doc" in result + assert "2024-06-01T12:00:00Z" in result + assert "http://link/f1" in result + + +@pytest.mark.asyncio +async def test_search_detailed_false_output_excludes_metadata(): + """detailed=False omits modified time and link from output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "f1", + "My Doc", + "application/vnd.google-apps.document", + modified="2024-06-01T12:00:00Z", + link="http://link/f1", + ) + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my doc", + detailed=False, + ) + + assert "My Doc" in result + assert "f1" in result + assert "2024-06-01T12:00:00Z" not in result + assert "http://link/f1" not in result + + +@pytest.mark.asyncio +async def test_search_detailed_true_with_size(): + """When the item has a size field, detailed=True includes it in output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("f2", "Big File", "application/pdf", size="102400"), + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="big", + detailed=True, + ) + + assert "102400" in result + + +@pytest.mark.asyncio +async def test_search_detailed_true_requests_extra_api_fields(): + """detailed=True passes full fields string to the Drive API.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="anything", + detailed=True, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" in call_kwargs["fields"] + assert "webViewLink" in call_kwargs["fields"] + assert "size" in call_kwargs["fields"] + + +@pytest.mark.asyncio +async def test_search_detailed_false_requests_compact_api_fields(): + """detailed=False passes compact fields string to the Drive API.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="anything", + detailed=False, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" not in call_kwargs["fields"] + assert "webViewLink" not in call_kwargs["fields"] + assert "size" not in call_kwargs["fields"] + + +@pytest.mark.asyncio +async def test_search_default_detailed_matches_detailed_true(): + """Omitting detailed produces the same output as detailed=True.""" + file = _make_file( + "f1", + "Doc", + "application/vnd.google-apps.document", + modified="2024-01-01T00:00:00Z", + link="http://l", + ) + + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": [file]} + result_default = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="doc", + ) + + mock_service.files().list().execute.return_value = {"files": [file]} + result_true = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="doc", + detailed=True, + ) + + assert result_default == result_true + + +# --------------------------------------------------------------------------- +# list_drive_items β€” detailed flag +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_output_includes_metadata(mock_resolve_folder): + """detailed=True (default) includes modified time and link in output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "id1", + "Report", + "application/vnd.google-apps.document", + modified="2024-03-15T08:00:00Z", + link="http://link/id1", + ) + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + assert "Report" in result + assert "2024-03-15T08:00:00Z" in result + assert "http://link/id1" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_false_output_excludes_metadata(mock_resolve_folder): + """detailed=False omits modified time and link from output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "id1", + "Report", + "application/vnd.google-apps.document", + modified="2024-03-15T08:00:00Z", + link="http://link/id1", + ) + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=False, + ) + + assert "Report" in result + assert "id1" in result + assert "2024-03-15T08:00:00Z" not in result + assert "http://link/id1" not in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_with_size(mock_resolve_folder): + """When item has a size field, detailed=True includes it in output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("id2", "Big File", "application/pdf", size="204800"), + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + assert "204800" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_requests_extra_api_fields(mock_resolve_folder): + """detailed=True passes full fields string to the Drive API.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" in call_kwargs["fields"] + assert "webViewLink" in call_kwargs["fields"] + assert "size" in call_kwargs["fields"] + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_false_requests_compact_api_fields(mock_resolve_folder): + """detailed=False passes compact fields string to the Drive API.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=False, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" not in call_kwargs["fields"] + assert "webViewLink" not in call_kwargs["fields"] + assert "size" not in call_kwargs["fields"] From 0e44ef924e1eb2a2d1bc1824ee20047e725d1ce2 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 26 Feb 2026 11:30:37 -0400 Subject: [PATCH 07/21] Update README.md --- README.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ed79e7c..6c15034 100644 --- a/README.md +++ b/README.md @@ -147,16 +147,7 @@ uv run main.py --tools gmail drive -### 1. One-Click Claude Desktop Install (Recommended) -1. **Download:** Grab the latest `google_workspace_mcp.dxt` from the β€œReleases” page -2. **Install:** Double-click the file – Claude Desktop opens and prompts you to **Install** -3. **Configure:** In Claude Desktop β†’ **Settings β†’ Extensions β†’ Google Workspace MCP**, paste your Google OAuth credentials -4. **Use it:** Start a new Claude chat and call any Google Workspace tool - -> -**Why DXT?** -> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download β†’ working MCP in **one click** – no terminal, no JSON editing, no version conflicts. #### Required Configuration
@@ -192,6 +183,17 @@ Claude Desktop stores these securely in the OS keychain; set them once in the ex --- +### One-Click Claude Desktop Install (Claude Desktop Only, Stdio, Single User) + +1. **Download:** Grab the latest `google_workspace_mcp.dxt` from the β€œReleases” page +2. **Install:** Double-click the file – Claude Desktop opens and prompts you to **Install** +3. **Configure:** In Claude Desktop β†’ **Settings β†’ Extensions β†’ Google Workspace MCP**, paste your Google OAuth credentials +4. **Use it:** Start a new Claude chat and call any Google Workspace tool + +> +**Why DXT?** +> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download β†’ working MCP in **one click** – no terminal, no JSON editing, no version conflicts. +
From 1fab9a82febfdead9acf2bdd15072f4613af51fd Mon Sep 17 00:00:00 2001 From: Francisco Date: Fri, 27 Feb 2026 11:21:18 +1300 Subject: [PATCH 08/21] gdrive - Moving nextPageToken to the end --- gdrive/drive_tools.py | 8 ++++---- tests/gdrive/test_drive_tools.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index f7cfa9f..30e9166 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -117,14 +117,14 @@ async def search_drive_files( next_token = results.get("nextPageToken") header = f"Found {len(files)} files for {user_google_email} matching '{query}':" - if next_token: - header += f"\nnextPageToken: {next_token}" formatted_files_text_parts = [header] for item in files: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_files_text_parts.append( f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' ) + if next_token: + formatted_files_text_parts.append(f"nextPageToken: {next_token}") text_output = "\n".join(formatted_files_text_parts) return text_output @@ -463,14 +463,14 @@ async def list_drive_items( next_token = results.get("nextPageToken") header = f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" - if next_token: - header += f"\nnextPageToken: {next_token}" formatted_items_text_parts = [header] for item in files: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_items_text_parts.append( f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' ) + if next_token: + formatted_items_text_parts.append(f"nextPageToken: {next_token}") text_output = "\n".join(formatted_items_text_parts) return text_output diff --git a/tests/gdrive/test_drive_tools.py b/tests/gdrive/test_drive_tools.py index e8c607a..48da4b3 100644 --- a/tests/gdrive/test_drive_tools.py +++ b/tests/gdrive/test_drive_tools.py @@ -57,7 +57,7 @@ async def test_search_drive_files_page_token_passed_to_api(): @pytest.mark.asyncio async def test_search_drive_files_next_page_token_in_output(): - """nextPageToken from the API response is appended to the output.""" + """nextPageToken from the API response is appended at the end of the output.""" mock_service = Mock() mock_service.files().list().execute.return_value = { "files": [ @@ -78,7 +78,7 @@ async def test_search_drive_files_next_page_token_in_output(): query="notes", ) - assert "nextPageToken: next_tok_xyz" in result + assert result.endswith("nextPageToken: next_tok_xyz") @pytest.mark.asyncio @@ -143,7 +143,7 @@ async def test_list_drive_items_page_token_passed_to_api(mock_resolve_folder): @pytest.mark.asyncio @patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder): - """nextPageToken from the API response is appended to the output.""" + """nextPageToken from the API response is appended at the end of the output.""" mock_resolve_folder.return_value = "root" mock_service = Mock() mock_service.files().list().execute.return_value = { @@ -164,7 +164,7 @@ async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder): user_google_email="user@example.com", ) - assert "nextPageToken: next_list_tok" in result + assert result.endswith("nextPageToken: next_list_tok") @pytest.mark.asyncio From bb197243cdf064c71a5846ff053749d6b0f60466 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Fri, 27 Feb 2026 16:59:18 -0400 Subject: [PATCH 09/21] pr feedback --- auth/google_auth.py | 2 ++ main.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/auth/google_auth.py b/auth/google_auth.py index 9c452ff..c5086e7 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -524,11 +524,13 @@ def handle_auth_callback( credentials = Credentials( token=credentials.token, refresh_token=credentials.refresh_token, + id_token=getattr(credentials, "id_token", None), token_uri=credentials.token_uri, client_id=credentials.client_id, client_secret=credentials.client_secret, scopes=list(granted), expiry=credentials.expiry, + quota_project_id=getattr(credentials, "quota_project_id", None), ) # Get user info to determine user_id (using email here) diff --git a/main.py b/main.py index 6f7ff26..f22e16c 100644 --- a/main.py +++ b/main.py @@ -311,7 +311,7 @@ def main(): ) set_enabled_tool_names(set(tier_tools)) except Exception as e: - safe_print(f"❌ Error loading tools for tier '{args.tool_tier}': {e}") + print(f"Error loading tools for tier '{args.tool_tier}': {e}", file=sys.stderr) sys.exit(1) elif args.tool_tier is not None: # Use tier-based tool selection, optionally filtered by services From e394ad90e2b5edde2394c01b2ffb5405e1f0190b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Feb 2026 21:00:00 +0000 Subject: [PATCH 10/21] style: auto-fix ruff lint and format --- main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index c90f666..07f3cd2 100644 --- a/main.py +++ b/main.py @@ -309,7 +309,10 @@ def main(): tier_tools, _ = resolve_tools_from_tier(args.tool_tier, tools_to_import) set_enabled_tool_names(set(tier_tools)) except Exception as e: - print(f"Error loading tools for tier '{args.tool_tier}': {e}", file=sys.stderr) + print( + f"Error loading tools for tier '{args.tool_tier}': {e}", + file=sys.stderr, + ) sys.exit(1) elif args.tool_tier is not None: # Use tier-based tool selection, optionally filtered by services From 9dc9b1c825b9dc8597e65d091d7891a41e95d8ce Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 10:06:50 -0400 Subject: [PATCH 11/21] pkce fix --- .beads/issues.jsonl | 2 ++ auth/google_auth.py | 36 ++++++++++++++++++++++++++--------- auth/oauth21_session_store.py | 2 ++ uv.lock | 2 +- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e319d18..154666c 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"id":"google_workspace_mcp-016","title":"fix: correct MCP registry PyPI ownership metadata","description":"Twine/PyPI rejects project.urls mcp-name because URL values must be valid URLs. For MCP registry PyPI verification, use README marker mcp-name: \u003cserver-name\u003e and ensure server.json name uses io.github.\u003cuser\u003e/\u003cserver\u003e format.","status":"closed","priority":1,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T20:04:06.49156-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T20:05:35.18854-05:00","closed_at":"2026-02-08T20:05:35.18854-05:00","close_reason":"Closed"} {"id":"google_workspace_mcp-0fl","title":"enh: add MCP registry publish to local release.py flow","description":"Extend scripts/release.py to sync server.json version and publish to MCP Registry via mcp-publisher during local release process, so publishing does not depend on GitHub Actions.","status":"closed","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T21:03:51.388408-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T21:06:05.334395-05:00","closed_at":"2026-02-08T21:06:05.334395-05:00","close_reason":"Closed"} +{"id":"google_workspace_mcp-0lv","title":"fix: preserve PKCE code_verifier across legacy OAuth callback","status":"closed","priority":1,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-28T09:56:17.914665-04:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-28T09:58:46.892165-04:00","closed_at":"2026-02-28T09:58:46.892165-04:00","close_reason":"Implemented PKCE code_verifier state continuity in legacy OAuth callback path and added tests"} {"id":"google_workspace_mcp-2mc","title":"release: cut next PyPI version and publish MCP registry entry","description":"Run local release flow: uv run python scripts/release.py to publish PyPI + MCP Registry via mcp-publisher. Verify package version on PyPI and server listing in registry search endpoint.","status":"open","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T20:00:39.779476-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T21:06:15.613447-05:00"} {"id":"google_workspace_mcp-3bn","title":"Fix AppScript run_script_function schema for Gemini API","description":"The run_script_function tool has a 'parameters' parameter defined as Optional[List[Any]] which causes schema generation issues with the Gemini API. The error is: 'GenerateContentRequest.tools[0].function_declarations[125].parameters.properties[parameters].items: missing field'. Need to fix the type annotation to generate proper JSON schema with items field.","status":"closed","priority":2,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-09T14:16:48.857746-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-09T14:21:43.5927-05:00","closed_at":"2026-02-09T14:21:43.5927-05:00","close_reason":"Fixed by changing parameters type from Optional[List[Any]] to Optional[list] in run_script_function. This ensures proper JSON schema generation with items field for Gemini API compatibility."} {"id":"google_workspace_mcp-631","title":"Address copilot feedback for docs/sheets hyperlink range and extraction","status":"closed","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T18:36:22.330879-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T18:38:04.356856-05:00","closed_at":"2026-02-08T18:38:04.356856-05:00","close_reason":"Closed"} @@ -12,6 +13,7 @@ {"id":"google_workspace_mcp-gpb","title":"Address PR feedback for docs list nesting and sheets hyperlink fetch","status":"closed","priority":2,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T17:48:48.31354-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T17:51:53.608353-05:00","closed_at":"2026-02-08T17:51:53.608353-05:00","close_reason":"Closed"} {"id":"google_workspace_mcp-ic8","title":"enh: support writing hyperlink URLs in modify_sheet_values","description":"Issue #434 also requested hyperlink creation/writes. Current implementation reads hyperlinks in read_sheet_values but modify_sheet_values does not expose first-class hyperlink writes.","status":"open","priority":3,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T17:42:10.590658-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T17:42:10.590658-05:00"} {"id":"google_workspace_mcp-jf2","title":"ci: make PyPI publish step rerun-safe with skip-existing","description":"GitHub Actions reruns on same tag fail because PyPI rejects duplicate file uploads. Add skip-existing=true to pypa/gh-action-pypi-publish so reruns proceed to MCP publish.","status":"closed","priority":2,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-08T20:59:58.461102-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-08T21:00:32.121469-05:00","closed_at":"2026-02-08T21:00:32.121469-05:00","close_reason":"Closed"} +{"id":"google_workspace_mcp-le6","title":"test: stabilize oauth callback redirect URI tests with OAuthConfig singleton reset","description":"tests/test_oauth_callback_server.py currently fails in this environment because get_oauth_redirect_uri uses cached OAuthConfig state that ignores per-test env var mutations. Add deterministic config reset/fixture strategy.","status":"open","priority":3,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-28T09:59:11.402699-04:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-28T09:59:11.402699-04:00"} {"id":"google_workspace_mcp-qfl","title":"Fix stdio multi-account session binding","status":"in_progress","priority":1,"issue_type":"task","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-07T13:27:09.466282-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-07T13:27:22.857227-05:00"} {"id":"google_workspace_mcp-qr5","title":"fix: include RFC Message-ID threading headers in thread content output","status":"closed","priority":2,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-11T11:44:41.966911-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-11T11:46:11.355237-05:00","closed_at":"2026-02-11T11:46:11.355237-05:00","close_reason":"Closed"} {"id":"google_workspace_mcp-xia","title":"fix: CLI should unwrap FastAPI Body defaults when invoking tools","description":"CLI mode invokes tool functions directly and currently passes FastAPI Body marker objects as defaults for omitted args. This breaks gmail send/draft with errors like Body has no attribute lower/len. Update CLI invocation to normalize Param defaults and return clear missing-required errors.","status":"closed","priority":1,"issue_type":"bug","owner":"tbarrettwilsdon@gmail.com","created_at":"2026-02-10T12:33:06.83139-05:00","created_by":"Taylor Wilsdon","updated_at":"2026-02-10T12:36:35.051947-05:00","closed_at":"2026-02-10T12:36:35.051947-05:00","close_reason":"Implemented CLI FastAPI default normalization + regression tests","labels":["cli","gmail"]} diff --git a/auth/google_auth.py b/auth/google_auth.py index 6b16d82..6853e4d 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -291,16 +291,27 @@ def check_client_secrets() -> Optional[str]: def create_oauth_flow( - scopes: List[str], redirect_uri: str, state: Optional[str] = None + scopes: List[str], + redirect_uri: str, + state: Optional[str] = None, + code_verifier: Optional[str] = None, ) -> Flow: """Creates an OAuth flow using environment variables or client secrets file.""" + flow_kwargs = { + "scopes": scopes, + "redirect_uri": redirect_uri, + "state": state, + } + if code_verifier: + flow_kwargs["code_verifier"] = code_verifier + # Preserve the original verifier when re-creating the flow in callback. + flow_kwargs["autogenerate_code_verifier"] = False + # Try environment variables first env_config = load_client_secrets_from_env() if env_config: # Use client config directly - flow = Flow.from_client_config( - env_config, scopes=scopes, redirect_uri=redirect_uri, state=state - ) + flow = Flow.from_client_config(env_config, **flow_kwargs) logger.debug("Created OAuth flow from environment variables") return flow @@ -312,9 +323,7 @@ def create_oauth_flow( flow = Flow.from_client_secrets_file( CONFIG_CLIENT_SECRETS_PATH, - scopes=scopes, - redirect_uri=redirect_uri, - state=state, + **flow_kwargs, ) logger.debug( f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}" @@ -389,7 +398,11 @@ async def start_auth_flow( ) store = get_oauth21_session_store() - store.store_oauth_state(oauth_state, session_id=session_id) + store.store_oauth_state( + oauth_state, + session_id=session_id, + code_verifier=flow.code_verifier, + ) logger.info( f"Auth flow started for {user_display_name}. Advise user to visit: {auth_url}" @@ -496,7 +509,12 @@ def handle_auth_callback( state_info.get("session_id") or "", ) - flow = create_oauth_flow(scopes=scopes, redirect_uri=redirect_uri, state=state) + flow = create_oauth_flow( + scopes=scopes, + redirect_uri=redirect_uri, + state=state, + code_verifier=state_info.get("code_verifier"), + ) # Exchange the authorization code for credentials # Note: fetch_token will use the redirect_uri configured in the flow diff --git a/auth/oauth21_session_store.py b/auth/oauth21_session_store.py index 2893154..f659de2 100644 --- a/auth/oauth21_session_store.py +++ b/auth/oauth21_session_store.py @@ -221,6 +221,7 @@ class OAuth21SessionStore: state: str, session_id: Optional[str] = None, expires_in_seconds: int = 600, + code_verifier: Optional[str] = None, ) -> None: """Persist an OAuth state value for later validation.""" if not state: @@ -236,6 +237,7 @@ class OAuth21SessionStore: "session_id": session_id, "expires_at": expiry, "created_at": now, + "code_verifier": code_verifier, } logger.debug( "Stored OAuth state %s (expires at %s)", diff --git a/uv.lock b/uv.lock index 1da1ac2..7842e03 100644 --- a/uv.lock +++ b/uv.lock @@ -2035,7 +2035,7 @@ wheels = [ [[package]] name = "workspace-mcp" -version = "1.12.0" +version = "1.13.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 9f329fc9353aae7e208bdac651e5751474bc836a Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 11:14:22 -0400 Subject: [PATCH 12/21] ruff --- main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index c90f666..07f3cd2 100644 --- a/main.py +++ b/main.py @@ -309,7 +309,10 @@ def main(): tier_tools, _ = resolve_tools_from_tier(args.tool_tier, tools_to_import) set_enabled_tool_names(set(tier_tools)) except Exception as e: - print(f"Error loading tools for tier '{args.tool_tier}': {e}", file=sys.stderr) + print( + f"Error loading tools for tier '{args.tool_tier}': {e}", + file=sys.stderr, + ) sys.exit(1) elif args.tool_tier is not None: # Use tier-based tool selection, optionally filtered by services From f2986dcf2f6ab9bb8870ec9911a4a8141b767423 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 11:19:19 -0400 Subject: [PATCH 13/21] pr feedback & readme update --- README.md | 18 ++++++++++++++++++ main.py | 23 +++++++++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6c15034..2cde17a 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,21 @@ Read-only mode provides secure, restricted access by: - Automatically filtering out tools that require write permissions at startup - Allowing read operations: list, get, search, and export across all services +**πŸ” Granular Permissions** +```bash +# Per-service permission levels +uv run main.py --permissions gmail:organize drive:readonly + +# Combine permissions with tier filtering +uv run main.py --permissions gmail:send drive:full --tool-tier core +``` +Granular permissions mode provides service-by-service scope control: +- Format: `service:level` (one entry per service) +- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative) +- Other services currently support: `readonly`, `full` +- `--permissions` and `--read-only` are mutually exclusive +- With `--tool-tier`, only tier-matched tools are enabled and only services with matching tier tools are imported + **β˜… Tool Tiers** ```bash uv run main.py --tool-tier core # ● Essential tools only @@ -738,6 +753,9 @@ uv run main.py --tool-tier complete # Enable all availabl uv run main.py --tools gmail drive --tool-tier core # Core tools for specific services uv run main.py --tools gmail --tool-tier extended # Extended Gmail functionality only uv run main.py --tools docs sheets --tool-tier complete # Full access to Docs and Sheets + +# Combine tier selection with granular permission levels +uv run main.py --permissions gmail:organize drive:full --tool-tier core ``` ## πŸ“‹ Credential Configuration diff --git a/main.py b/main.py index 07f3cd2..8dc28be 100644 --- a/main.py +++ b/main.py @@ -91,6 +91,23 @@ def configure_safe_logging(): handler.setFormatter(safe_formatter) +def resolve_permissions_mode_selection( + permission_services: list[str], tool_tier: str | None +) -> tuple[list[str], set[str] | None]: + """ + Resolve service imports and optional tool-name filtering for --permissions mode. + + When a tier is specified, both: + - imported services are narrowed to services with tier-matched tools + - registered tools are narrowed to the resolved tool names + """ + if tool_tier is None: + return permission_services, None + + tier_tools, tier_services = resolve_tools_from_tier(tool_tier, permission_services) + return tier_services, set(tier_tools) + + def main(): """ Main entry point for the Google Workspace MCP server. @@ -306,8 +323,10 @@ def main(): if args.tool_tier is not None: # Combine with tier filtering within the permission-selected services try: - tier_tools, _ = resolve_tools_from_tier(args.tool_tier, tools_to_import) - set_enabled_tool_names(set(tier_tools)) + tools_to_import, tier_tool_filter = resolve_permissions_mode_selection( + tools_to_import, args.tool_tier + ) + set_enabled_tool_names(tier_tool_filter) except Exception as e: print( f"Error loading tools for tier '{args.tool_tier}': {e}", From edf9e94829ae8949d3d7536edf68f4926f7fcd4f Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 11:40:29 -0400 Subject: [PATCH 14/21] cachebusting for oauth endpoints, more tests, startup check for perms --- core/server.py | 43 ++++++++++++++++++++++++++++++++++++++++++- main.py | 17 ++++++++++++++++- tests/test_scopes.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/core/server.py b/core/server.py index 97d1be6..7b68848 100644 --- a/core/server.py +++ b/core/server.py @@ -1,10 +1,12 @@ +import hashlib import logging import os -from typing import List, Optional +from typing import Callable, List, Optional from importlib import metadata from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from starlette.applications import Starlette +from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.middleware import Middleware @@ -38,6 +40,38 @@ _legacy_callback_registered = False session_middleware = Middleware(MCPSessionMiddleware) +def _compute_scope_fingerprint() -> str: + """Compute a short hash of the current scope configuration for cache-busting.""" + scopes_str = ",".join(sorted(get_current_scopes())) + return hashlib.sha256(scopes_str.encode()).hexdigest()[:12] + + +class OAuthMetadataCacheBustMiddleware(BaseHTTPMiddleware): + """Override the upstream 1-hour Cache-Control on OAuth discovery endpoints. + + The MCP SDK sets ``Cache-Control: public, max-age=3600`` on the + ``.well-known`` metadata responses. When the server is restarted with a + different ``--permissions`` or ``--read-only`` configuration, browsers / + MCP clients can serve stale discovery docs that advertise the wrong + scopes, causing the OAuth flow to silently fail. + + This middleware replaces the cache header with ``no-store`` and adds an + ``ETag`` derived from the current scope set so that intermediary caches + that *do* store the response will still invalidate on config change. + """ + + def __init__(self, app: Starlette, scope_fingerprint: str) -> None: + super().__init__(app) + self._etag = f'"{scope_fingerprint}"' + + async def dispatch(self, request: Request, call_next: Callable): + response = await call_next(request) + if request.url.path.startswith("/.well-known/"): + response.headers["Cache-Control"] = "no-store, must-revalidate" + response.headers["ETag"] = self._etag + return response + + # Custom FastMCP that adds secure middleware stack for OAuth 2.1 class SecureFastMCP(FastMCP): def http_app(self, **kwargs) -> "Starlette": @@ -48,6 +82,13 @@ class SecureFastMCP(FastMCP): # Session Management - extracts session info for MCP context app.user_middleware.insert(0, session_middleware) + # Prevent browser caching of OAuth discovery endpoints across config changes + fingerprint = _compute_scope_fingerprint() + app.user_middleware.insert( + 0, + Middleware(OAuthMetadataCacheBustMiddleware, scope_fingerprint=fingerprint), + ) + # Rebuild middleware stack app.middleware_stack = app.build_middleware_stack() logger.info("Added middleware stack: Session Management") diff --git a/main.py b/main.py index 8dc28be..d5c288d 100644 --- a/main.py +++ b/main.py @@ -108,6 +108,13 @@ def resolve_permissions_mode_selection( return tier_services, set(tier_tools) +def narrow_permissions_to_services( + permissions: dict[str, str], services: list[str] +) -> dict[str, str]: + """Restrict permission entries to the provided service list order.""" + return {service: permissions[service] for service in services if service in permissions} + + def main(): """ Main entry point for the Google Workspace MCP server. @@ -199,6 +206,13 @@ def main(): file=sys.stderr, ) sys.exit(1) + if args.permissions and args.tools is not None: + print( + "Error: --permissions and --tools cannot be combined. " + "Select services via --permissions (optionally with --tool-tier).", + file=sys.stderr, + ) + sys.exit(1) # Set port and base URI once for reuse throughout the function port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) @@ -315,7 +329,6 @@ def main(): except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) - set_permissions(perms) # Permissions implicitly defines which services to load tools_to_import = list(perms.keys()) set_enabled_tool_names(None) @@ -327,12 +340,14 @@ def main(): tools_to_import, args.tool_tier ) set_enabled_tool_names(tier_tool_filter) + perms = narrow_permissions_to_services(perms, tools_to_import) except Exception as e: print( f"Error loading tools for tier '{args.tool_tier}': {e}", file=sys.stderr, ) sys.exit(1) + set_permissions(perms) elif args.tool_tier is not None: # Use tier-based tool selection, optionally filtered by services try: diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 43448b1..502df3d 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -12,6 +12,7 @@ import os sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from auth.scopes import ( + BASE_SCOPES, CALENDAR_READONLY_SCOPE, CALENDAR_SCOPE, CONTACTS_READONLY_SCOPE, @@ -31,6 +32,8 @@ from auth.scopes import ( has_required_scopes, set_read_only, ) +from auth.permissions import get_scopes_for_permission, set_permissions +import auth.permissions as permissions_module class TestDocsScopes: @@ -195,3 +198,34 @@ class TestHasRequiredScopes: available = [GMAIL_MODIFY_SCOPE] required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE] assert not has_required_scopes(available, required) + + +class TestGranularPermissionsScopes: + """Tests for granular permissions scope generation path.""" + + def setup_method(self): + set_read_only(False) + permissions_module._PERMISSIONS = None + + def teardown_method(self): + set_read_only(False) + permissions_module._PERMISSIONS = None + + def test_permissions_mode_returns_base_plus_permission_scopes(self): + set_permissions({"gmail": "send", "drive": "readonly"}) + scopes = get_scopes_for_tools(["calendar"]) # ignored in permissions mode + + expected = set(BASE_SCOPES) + expected.update(get_scopes_for_permission("gmail", "send")) + expected.update(get_scopes_for_permission("drive", "readonly")) + assert set(scopes) == expected + + def test_permissions_mode_overrides_read_only_and_full_maps(self): + set_read_only(True) + without_permissions = get_scopes_for_tools(["drive"]) + assert DRIVE_READONLY_SCOPE in without_permissions + + set_permissions({"gmail": "readonly"}) + with_permissions = get_scopes_for_tools(["drive"]) + assert GMAIL_READONLY_SCOPE in with_permissions + assert DRIVE_READONLY_SCOPE not in with_permissions From 58256e42eea6e949a6f57bc68f9bb44bce7cda09 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 28 Feb 2026 15:40:51 +0000 Subject: [PATCH 15/21] style: auto-fix ruff lint and format --- main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index d5c288d..03021e8 100644 --- a/main.py +++ b/main.py @@ -112,7 +112,9 @@ def narrow_permissions_to_services( permissions: dict[str, str], services: list[str] ) -> dict[str, str]: """Restrict permission entries to the provided service list order.""" - return {service: permissions[service] for service in services if service in permissions} + return { + service: permissions[service] for service in services if service in permissions + } def main(): From 34ada2c7adbbdf0f575c51b8056f439e22cb2323 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 11:50:09 -0400 Subject: [PATCH 16/21] better cachce management --- core/server.py | 55 ++++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/core/server.py b/core/server.py index 7b68848..87e32ea 100644 --- a/core/server.py +++ b/core/server.py @@ -1,13 +1,13 @@ import hashlib import logging import os -from typing import Callable, List, Optional +from typing import List, Optional from importlib import metadata from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from starlette.applications import Starlette -from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request +from starlette.responses import Response from starlette.middleware import Middleware from fastmcp import FastMCP @@ -46,31 +46,27 @@ def _compute_scope_fingerprint() -> str: return hashlib.sha256(scopes_str.encode()).hexdigest()[:12] -class OAuthMetadataCacheBustMiddleware(BaseHTTPMiddleware): - """Override the upstream 1-hour Cache-Control on OAuth discovery endpoints. +def _wrap_well_known_endpoint(endpoint, etag: str): + """Wrap a well-known metadata endpoint to prevent browser caching. - The MCP SDK sets ``Cache-Control: public, max-age=3600`` on the - ``.well-known`` metadata responses. When the server is restarted with a - different ``--permissions`` or ``--read-only`` configuration, browsers / - MCP clients can serve stale discovery docs that advertise the wrong - scopes, causing the OAuth flow to silently fail. + The MCP SDK hardcodes ``Cache-Control: public, max-age=3600`` on discovery + responses. When the server restarts with different ``--permissions`` or + ``--read-only`` flags, browsers / MCP clients serve stale metadata that + advertises the wrong scopes, causing OAuth to silently fail. - This middleware replaces the cache header with ``no-store`` and adds an - ``ETag`` derived from the current scope set so that intermediary caches - that *do* store the response will still invalidate on config change. + The wrapper overrides the header to ``no-store`` and adds an ``ETag`` + derived from the current scope set so intermediary caches that ignore + ``no-store`` still see a fingerprint change. """ - def __init__(self, app: Starlette, scope_fingerprint: str) -> None: - super().__init__(app) - self._etag = f'"{scope_fingerprint}"' - - async def dispatch(self, request: Request, call_next: Callable): - response = await call_next(request) - if request.url.path.startswith("/.well-known/"): - response.headers["Cache-Control"] = "no-store, must-revalidate" - response.headers["ETag"] = self._etag + async def _no_cache_endpoint(request: Request) -> Response: + response = await endpoint(request) + response.headers["Cache-Control"] = "no-store, must-revalidate" + response.headers["ETag"] = etag return response + return _no_cache_endpoint + # Custom FastMCP that adds secure middleware stack for OAuth 2.1 class SecureFastMCP(FastMCP): @@ -82,13 +78,6 @@ class SecureFastMCP(FastMCP): # Session Management - extracts session info for MCP context app.user_middleware.insert(0, session_middleware) - # Prevent browser caching of OAuth discovery endpoints across config changes - fingerprint = _compute_scope_fingerprint() - app.user_middleware.insert( - 0, - Middleware(OAuthMetadataCacheBustMiddleware, scope_fingerprint=fingerprint), - ) - # Rebuild middleware stack app.middleware_stack = app.build_middleware_stack() logger.info("Added middleware stack: Session Management") @@ -428,14 +417,18 @@ def configure_server_for_http(): "OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth" ) - # Explicitly mount well-known routes from the OAuth provider - # These should be auto-mounted but we ensure they're available + # Mount well-known routes with cache-busting headers. + # The MCP SDK hardcodes Cache-Control: public, max-age=3600 + # on discovery responses which causes stale-scope bugs when + # the server is restarted with a different --permissions config. try: + scope_etag = f'"{_compute_scope_fingerprint()}"' well_known_routes = provider.get_well_known_routes() for route in well_known_routes: logger.info(f"Mounting OAuth well-known route: {route.path}") + wrapped = _wrap_well_known_endpoint(route.endpoint, scope_etag) server.custom_route(route.path, methods=list(route.methods))( - route.endpoint + wrapped ) except Exception as e: logger.warning(f"Could not mount well-known routes: {e}") From 768ec5eef088c81a4c05d369cebd3a9473c02c79 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 16:17:43 -0400 Subject: [PATCH 17/21] refac --- README.md | 3 ++- auth/permissions.py | 2 +- main.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2cde17a..4793535 100644 --- a/README.md +++ b/README.md @@ -573,7 +573,8 @@ Granular permissions mode provides service-by-service scope control: - Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative) - Other services currently support: `readonly`, `full` - `--permissions` and `--read-only` are mutually exclusive -- With `--tool-tier`, only tier-matched tools are enabled and only services with matching tier tools are imported +- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`) +- With `--tool-tier`, only tier-matched tools are enabled and only services that have tools in the selected tier are imported **β˜… Tool Tiers** ```bash diff --git a/auth/permissions.py b/auth/permissions.py index 98de857..caa38d0 100644 --- a/auth/permissions.py +++ b/auth/permissions.py @@ -179,7 +179,7 @@ def get_scopes_for_permission(service: str, level: str) -> List[str]: f"Valid levels: {valid}" ) - return list(set(cumulative)) + return sorted(set(cumulative)) def get_all_permission_scopes() -> List[str]: diff --git a/main.py b/main.py index 03021e8..8fb97c5 100644 --- a/main.py +++ b/main.py @@ -190,7 +190,7 @@ def main(): "Example: --permissions gmail:organize drive:readonly. " "Gmail levels: readonly, organize, drafts, send, full (cumulative). " "Other services: readonly, full. " - "Mutually exclusive with --read-only." + "Mutually exclusive with --read-only and --tools." ), ) args = parser.parse_args() From 252aa2aede1f61c459f1d7836cf961802d0bb1d8 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 16:56:30 -0400 Subject: [PATCH 18/21] tests --- tests/gdrive/test_create_drive_folder.py | 147 +++++++++++++++++++++++ tests/test_main_permissions_tier.py | 60 +++++++++ tests/test_permissions.py | 118 ++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 tests/gdrive/test_create_drive_folder.py create mode 100644 tests/test_main_permissions_tier.py create mode 100644 tests/test_permissions.py diff --git a/tests/gdrive/test_create_drive_folder.py b/tests/gdrive/test_create_drive_folder.py new file mode 100644 index 0000000..0860e73 --- /dev/null +++ b/tests/gdrive/test_create_drive_folder.py @@ -0,0 +1,147 @@ +""" +Unit tests for create_drive_folder tool. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gdrive.drive_tools import _create_drive_folder_impl as _raw_create_drive_folder + + +def _make_service(created_response): + """Build a mock Drive service whose files().create().execute returns *created_response*.""" + execute = MagicMock(return_value=created_response) + create = MagicMock() + create.return_value.execute = execute + files = MagicMock() + files.return_value.create = create + service = MagicMock() + service.files = files + return service + + +@pytest.mark.asyncio +async def test_create_folder_root_skips_resolve(): + """Parent 'root' should pass through resolve_folder_id and produce correct output.""" + api_response = { + "id": "new-folder-id", + "name": "My Folder", + "webViewLink": "https://drive.google.com/drive/folders/new-folder-id", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="root", + ): + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="My Folder", + parent_folder_id="root", + ) + + assert "new-folder-id" in result + assert "My Folder" in result + assert "https://drive.google.com/drive/folders/new-folder-id" in result + + +@pytest.mark.asyncio +async def test_create_folder_custom_parent_resolves(): + """A non-root parent_folder_id should go through resolve_folder_id.""" + api_response = { + "id": "new-folder-id", + "name": "Sub Folder", + "webViewLink": "https://drive.google.com/drive/folders/new-folder-id", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="resolved-parent-id", + ) as mock_resolve: + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="Sub Folder", + parent_folder_id="shortcut-id", + ) + + mock_resolve.assert_awaited_once_with(service, "shortcut-id") + # The output message uses the original parent_folder_id, not the resolved one + assert "shortcut-id" in result + # But the API call should use the resolved ID + service.files().create.assert_called_once_with( + body={ + "name": "Sub Folder", + "mimeType": "application/vnd.google-apps.folder", + "parents": ["resolved-parent-id"], + }, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + + +@pytest.mark.asyncio +async def test_create_folder_passes_correct_metadata(): + """Verify the metadata dict sent to the Drive API is correct.""" + api_response = { + "id": "abc123", + "name": "Test", + "webViewLink": "https://drive.google.com/drive/folders/abc123", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="resolved-id", + ): + await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="Test", + parent_folder_id="some-parent", + ) + + service.files().create.assert_called_once_with( + body={ + "name": "Test", + "mimeType": "application/vnd.google-apps.folder", + "parents": ["resolved-id"], + }, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + + +@pytest.mark.asyncio +async def test_create_folder_missing_webviewlink(): + """When the API omits webViewLink, the result should have an empty link.""" + api_response = { + "id": "abc123", + "name": "NoLink", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="root", + ): + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="NoLink", + parent_folder_id="root", + ) + + assert "abc123" in result + assert "NoLink" in result diff --git a/tests/test_main_permissions_tier.py b/tests/test_main_permissions_tier.py new file mode 100644 index 0000000..2805521 --- /dev/null +++ b/tests/test_main_permissions_tier.py @@ -0,0 +1,60 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import main + + +def test_resolve_permissions_mode_selection_without_tier(): + services = ["gmail", "drive"] + resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection( + services, None + ) + assert resolved_services == services + assert tier_tool_filter is None + + +def test_resolve_permissions_mode_selection_with_tier_filters_services(monkeypatch): + def fake_resolve_tools_from_tier(tier, services): + assert tier == "core" + assert services == ["gmail", "drive", "slides"] + return ["search_gmail_messages"], ["gmail"] + + monkeypatch.setattr(main, "resolve_tools_from_tier", fake_resolve_tools_from_tier) + + resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection( + ["gmail", "drive", "slides"], "core" + ) + assert resolved_services == ["gmail"] + assert tier_tool_filter == {"search_gmail_messages"} + + +def test_narrow_permissions_to_services_keeps_selected_order(): + permissions = {"drive": "full", "gmail": "readonly", "calendar": "readonly"} + narrowed = main.narrow_permissions_to_services(permissions, ["gmail", "drive"]) + assert narrowed == {"gmail": "readonly", "drive": "full"} + + +def test_narrow_permissions_to_services_drops_non_selected_services(): + permissions = {"gmail": "send", "drive": "full"} + narrowed = main.narrow_permissions_to_services(permissions, ["gmail"]) + assert narrowed == {"gmail": "send"} + + +def test_permissions_and_tools_flags_are_rejected(monkeypatch, capsys): + monkeypatch.setattr(main, "configure_safe_logging", lambda: None) + monkeypatch.setattr( + sys, + "argv", + ["main.py", "--permissions", "gmail:readonly", "--tools", "gmail"], + ) + + with pytest.raises(SystemExit) as exc: + main.main() + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "--permissions and --tools cannot be combined" in captured.err diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..2d6c7d1 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,118 @@ +""" +Unit tests for granular per-service permission parsing and scope resolution. + +Covers parse_permissions_arg() validation (format, duplicates, unknown +service/level) and cumulative scope expansion in get_scopes_for_permission(). +""" + +import sys +import os + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from auth.permissions import ( + get_scopes_for_permission, + parse_permissions_arg, + SERVICE_PERMISSION_LEVELS, +) +from auth.scopes import ( + GMAIL_READONLY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_COMPOSE_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_SCOPE, + DRIVE_FILE_SCOPE, +) + + +class TestParsePermissionsArg: + """Tests for parse_permissions_arg().""" + + def test_single_valid_entry(self): + result = parse_permissions_arg(["gmail:readonly"]) + assert result == {"gmail": "readonly"} + + def test_multiple_valid_entries(self): + result = parse_permissions_arg(["gmail:organize", "drive:full"]) + assert result == {"gmail": "organize", "drive": "full"} + + def test_all_services_at_readonly(self): + entries = [f"{svc}:readonly" for svc in SERVICE_PERMISSION_LEVELS] + result = parse_permissions_arg(entries) + assert set(result.keys()) == set(SERVICE_PERMISSION_LEVELS.keys()) + + def test_missing_colon_raises(self): + with pytest.raises(ValueError, match="Invalid permission format"): + parse_permissions_arg(["gmail_readonly"]) + + def test_duplicate_service_raises(self): + with pytest.raises(ValueError, match="Duplicate service"): + parse_permissions_arg(["gmail:readonly", "gmail:full"]) + + def test_unknown_service_raises(self): + with pytest.raises(ValueError, match="Unknown service"): + parse_permissions_arg(["fakesvc:readonly"]) + + def test_unknown_level_raises(self): + with pytest.raises(ValueError, match="Unknown level"): + parse_permissions_arg(["gmail:superadmin"]) + + def test_empty_list_returns_empty(self): + assert parse_permissions_arg([]) == {} + + def test_extra_colon_in_value(self): + """A level containing a colon should fail as unknown level.""" + with pytest.raises(ValueError, match="Unknown level"): + parse_permissions_arg(["gmail:read:only"]) + + +class TestGetScopesForPermission: + """Tests for get_scopes_for_permission() cumulative scope expansion.""" + + def test_gmail_readonly_returns_readonly_scope(self): + scopes = get_scopes_for_permission("gmail", "readonly") + assert GMAIL_READONLY_SCOPE in scopes + + def test_gmail_organize_includes_readonly(self): + """Organize level should cumulatively include readonly scopes.""" + scopes = get_scopes_for_permission("gmail", "organize") + assert GMAIL_READONLY_SCOPE in scopes + assert GMAIL_LABELS_SCOPE in scopes + assert GMAIL_MODIFY_SCOPE in scopes + + def test_gmail_drafts_includes_organize_and_readonly(self): + scopes = get_scopes_for_permission("gmail", "drafts") + assert GMAIL_READONLY_SCOPE in scopes + assert GMAIL_LABELS_SCOPE in scopes + assert GMAIL_COMPOSE_SCOPE in scopes + + def test_drive_readonly_excludes_full(self): + scopes = get_scopes_for_permission("drive", "readonly") + assert DRIVE_READONLY_SCOPE in scopes + assert DRIVE_SCOPE not in scopes + assert DRIVE_FILE_SCOPE not in scopes + + def test_drive_full_includes_readonly(self): + scopes = get_scopes_for_permission("drive", "full") + assert DRIVE_READONLY_SCOPE in scopes + assert DRIVE_SCOPE in scopes + + def test_unknown_service_raises(self): + with pytest.raises(ValueError, match="Unknown service"): + get_scopes_for_permission("nonexistent", "readonly") + + def test_unknown_level_raises(self): + with pytest.raises(ValueError, match="Unknown permission level"): + get_scopes_for_permission("gmail", "nonexistent") + + def test_no_duplicate_scopes(self): + """Cumulative expansion should deduplicate scopes.""" + for service, levels in SERVICE_PERMISSION_LEVELS.items(): + for level_name, _ in levels: + scopes = get_scopes_for_permission(service, level_name) + assert len(scopes) == len(set(scopes)), ( + f"Duplicate scopes for {service}:{level_name}" + ) From bbdd61d08d90cf4ac91ea346c28b5de9e58041fc Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 17:12:30 -0400 Subject: [PATCH 19/21] fix attachments + test --- core/server.py | 3 +- tests/core/test_attachment_route.py | 69 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_attachment_route.py diff --git a/core/server.py b/core/server.py index 87e32ea..d21c9d5 100644 --- a/core/server.py +++ b/core/server.py @@ -472,10 +472,11 @@ async def health_check(request: Request): @server.custom_route("/attachments/{file_id}", methods=["GET"]) -async def serve_attachment(file_id: str): +async def serve_attachment(request: Request): """Serve a stored attachment file.""" from core.attachment_storage import get_attachment_storage + file_id = request.path_params["file_id"] storage = get_attachment_storage() metadata = storage.get_attachment_metadata(file_id) diff --git a/tests/core/test_attachment_route.py b/tests/core/test_attachment_route.py new file mode 100644 index 0000000..22ee04f --- /dev/null +++ b/tests/core/test_attachment_route.py @@ -0,0 +1,69 @@ +import pytest +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse + +from core.server import serve_attachment + + +def _build_request(file_id: str) -> Request: + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": f"/attachments/{file_id}", + "raw_path": f"/attachments/{file_id}".encode(), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("localhost", 8000), + "path_params": {"file_id": file_id}, + } + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + return Request(scope, receive) + + +@pytest.mark.asyncio +async def test_serve_attachment_uses_path_param_file_id(monkeypatch, tmp_path): + file_path = tmp_path / "sample.pdf" + file_path.write_bytes(b"%PDF-1.3\n") + captured = {} + + class DummyStorage: + def get_attachment_metadata(self, file_id): + captured["file_id"] = file_id + return {"filename": "sample.pdf", "mime_type": "application/pdf"} + + def get_attachment_path(self, _file_id): + return file_path + + monkeypatch.setattr( + "core.attachment_storage.get_attachment_storage", lambda: DummyStorage() + ) + + response = await serve_attachment(_build_request("abc123")) + + assert captured["file_id"] == "abc123" + assert isinstance(response, FileResponse) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_serve_attachment_404_when_metadata_missing(monkeypatch): + class DummyStorage: + def get_attachment_metadata(self, _file_id): + return None + + monkeypatch.setattr( + "core.attachment_storage.get_attachment_storage", lambda: DummyStorage() + ) + + response = await serve_attachment(_build_request("missing")) + + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + assert b"Attachment not found or expired" in response.body From f29f16e5910193ab1515fb8bd36e4eeeaebf0e13 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 17:43:44 -0400 Subject: [PATCH 20/21] refac --- gdrive/drive_tools.py | 19 +++++-------------- tests/gdrive/test_drive_tools.py | 9 ++------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index dd8927f..2669783 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -79,9 +79,8 @@ async def search_drive_files( detailed (bool): Whether to include size, modified time, and link in results. Defaults to True. Returns: - str: A formatted list of found files/folders with their details (ID, name, type, size, modified time, link). - Includes a nextPageToken line when more results are available. str: A formatted list of found files/folders with their details (ID, name, type, and optionally size, modified time, link). + Includes a nextPageToken line when more results are available. """ logger.info( f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}'" @@ -123,12 +122,6 @@ async def search_drive_files( header = f"Found {len(files)} files for {user_google_email} matching '{query}':" formatted_files_text_parts = [header] for item in files: - size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" - formatted_files_text_parts.append( - f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' - ) - if next_token: - formatted_files_text_parts.append(f"nextPageToken: {next_token}") if detailed: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_files_text_parts.append( @@ -138,6 +131,8 @@ async def search_drive_files( formatted_files_text_parts.append( f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' ) + if next_token: + formatted_files_text_parts.append(f"nextPageToken: {next_token}") text_output = "\n".join(formatted_files_text_parts) return text_output @@ -481,12 +476,6 @@ async def list_drive_items( header = f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" formatted_items_text_parts = [header] for item in files: - size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" - formatted_items_text_parts.append( - f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' - ) - if next_token: - formatted_items_text_parts.append(f"nextPageToken: {next_token}") if detailed: size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" formatted_items_text_parts.append( @@ -496,6 +485,8 @@ async def list_drive_items( formatted_items_text_parts.append( f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' ) + if next_token: + formatted_items_text_parts.append(f"nextPageToken: {next_token}") text_output = "\n".join(formatted_items_text_parts) return text_output diff --git a/tests/gdrive/test_drive_tools.py b/tests/gdrive/test_drive_tools.py index 4e80b96..f260e62 100644 --- a/tests/gdrive/test_drive_tools.py +++ b/tests/gdrive/test_drive_tools.py @@ -13,13 +13,6 @@ import os sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -from gdrive.drive_tools import list_drive_items, search_drive_files - - -def _unwrap(fn): - """Unwrap a decorator chain to the original async function.""" - if hasattr(fn, "fn"): - fn = fn.fn # FunctionTool wrapper (other server versions) from gdrive.drive_helpers import build_drive_list_params from gdrive.drive_tools import list_drive_items, search_drive_files @@ -205,6 +198,8 @@ async def test_list_drive_items_no_next_page_token_when_absent(mock_resolve_fold ) assert "nextPageToken" not in result + + # Helpers # --------------------------------------------------------------------------- From 8b69a49d4001721bbea7a3c9c115303ceb5aaef5 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sat, 28 Feb 2026 17:44:37 -0400 Subject: [PATCH 21/21] ruff --- gdrive/drive_tools.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index 2669783..65dcb95 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -473,7 +473,9 @@ async def list_drive_items( return f"No items found in folder '{folder_id}'." next_token = results.get("nextPageToken") - header = f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" + header = ( + f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" + ) formatted_items_text_parts = [header] for item in files: if detailed: