diff --git a/README.md b/README.md index c6146f1..7740390 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,11 @@ A production-ready MCP server that integrates all major Google Workspace service **@ Gmail** • ** Drive** • ** Calendar** ** Docs** -- Complete Gmail management, end to end coverage +- Complete Gmail management, end-to-end coverage - Full calendar management with advanced features - File operations with Office format support - Document creation, editing & comments -- Deep, exhaustive support for fine grained editing +- Deep, exhaustive support for fine-grained editing --- @@ -846,9 +846,7 @@ cp .env.oauth21 .env |------|------|-------------| | `list_calendars` | **Core** | List accessible calendars | | `get_events` | **Core** | Retrieve events with time range filtering | -| `create_event` | **Core** | Create events with attachments & reminders | -| `modify_event` | **Core** | Update existing events | -| `delete_event` | Extended | Remove events | +| `manage_event` | **Core** | Create, update, or delete calendar events | @@ -863,15 +861,11 @@ cp .env.oauth21 .env | `create_drive_file` | **Core** | Create files or fetch from URLs | | `create_drive_folder` | **Core** | Create empty folders in Drive or shared drives | | `import_to_google_doc` | **Core** | Import files (MD, DOCX, HTML, etc.) as Google Docs | -| `share_drive_file` | **Core** | Share file with users/groups/domains/anyone | | `get_drive_shareable_link` | **Core** | Get shareable links for a file | | `list_drive_items` | Extended | List folder contents | | `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming | | `update_drive_file` | Extended | Update file metadata, move between folders | -| `batch_share_drive_file` | Extended | Share file with multiple recipients | -| `update_drive_permission` | Extended | Modify permission role | -| `remove_drive_permission` | Extended | Revoke file access | -| `transfer_drive_ownership` | Extended | Transfer file ownership to another user | +| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership | | `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings | | `get_drive_file_permissions` | Complete | Get detailed file permissions | | `check_drive_file_public_access` | Complete | Check public sharing status | @@ -894,7 +888,9 @@ cp .env.oauth21 .env | `get_gmail_thread_content` | Extended | Get full thread content | | `modify_gmail_message_labels` | Extended | Modify message labels | | `list_gmail_labels` | Extended | List available labels | +| `list_gmail_filters` | Extended | List Gmail filters | | `manage_gmail_label` | Extended | Create/update/delete labels | +| `manage_gmail_filter` | Extended | Create or delete Gmail filters | | `draft_gmail_message` | Extended | Create drafts | | `get_gmail_threads_content_batch` | Complete | Batch retrieve thread content | | `batch_modify_gmail_message_labels` | Complete | Batch modify labels | @@ -965,7 +961,8 @@ Saved files expire after 1 hour and are cleaned up automatically. | `export_doc_to_pdf` | Extended | Export document to PDF | | `create_table_with_data` | Complete | Create data tables | | `debug_table_structure` | Complete | Debug table issues | -| `*_document_comments` | Complete | Read, Reply, Create, Resolve | +| `list_document_comments` | Complete | List all document comments | +| `manage_document_comment` | Complete | Create, reply to, or resolve comments | @@ -984,7 +981,9 @@ Saved files expire after 1 hour and are cleaned up automatically. | `get_spreadsheet_info` | Extended | Get spreadsheet metadata | | `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size | | `create_sheet` | Complete | Add sheets to existing files | -| `*_sheet_comment` | Complete | Read/create/reply/resolve comments | +| `list_spreadsheet_comments` | Complete | List all spreadsheet comments | +| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments | +| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules | @@ -998,7 +997,8 @@ Saved files expire after 1 hour and are cleaned up automatically. | `batch_update_presentation` | Extended | Apply multiple updates | | `get_page` | Extended | Get specific slide information | | `get_page_thumbnail` | Extended | Generate slide thumbnails | -| `*_presentation_comment` | Complete | Read/create/reply/resolve comments | +| `list_presentation_comments` | Complete | List all presentation comments | +| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments | @@ -1025,12 +1025,10 @@ Saved files expire after 1 hour and are cleaned up automatically. |------|------|-------------| | `list_tasks` | **Core** | List tasks with filtering | | `get_task` | **Core** | Retrieve task details | -| `create_task` | **Core** | Create tasks with hierarchy | -| `update_task` | **Core** | Modify task properties | -| `delete_task` | Extended | Remove tasks | -| `move_task` | Complete | Reposition tasks | -| `clear_completed_tasks` | Complete | Hide completed tasks | -| `*_task_list` | Complete | List/get/create/update/delete task lists | +| `manage_task` | **Core** | Create, update, delete, or move tasks | +| `list_task_lists` | Complete | List task lists | +| `get_task_list` | Complete | Get task list details | +| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks | @@ -1044,14 +1042,11 @@ Saved files expire after 1 hour and are cleaned up automatically. | `search_contacts` | **Core** | Search contacts by name, email, phone | | `get_contact` | **Core** | Retrieve detailed contact info | | `list_contacts` | **Core** | List contacts with pagination | -| `create_contact` | **Core** | Create new contacts | -| `update_contact` | Extended | Update existing contacts | -| `delete_contact` | Extended | Delete contacts | +| `manage_contact` | **Core** | Create, update, or delete contacts | | `list_contact_groups` | Extended | List contact groups/labels | | `get_contact_group` | Extended | Get group details with members | -| `batch_*_contacts` | Complete | Batch create/update/delete contacts | -| `*_contact_group` | Complete | Create/update/delete contact groups | -| `modify_contact_group_members` | Complete | Add/remove contacts from groups | +| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts | +| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership | @@ -1076,9 +1071,8 @@ Saved files expire after 1 hour and are cleaned up automatically. | Tool | Tier | Description | |------|------|-------------| -| `search_custom` | **Core** | Perform web searches | +| `search_custom` | **Core** | Perform web searches (supports site restrictions via sites parameter) | | `get_search_engine_info` | Complete | Retrieve search engine metadata | -| `search_custom_siterestrict` | Extended | Search within specific domains | @@ -1095,10 +1089,8 @@ Saved files expire after 1 hour and are cleaned up automatically. | `create_script_project` | **Core** | Create new standalone or bound project | | `update_script_content` | **Core** | Update or create script files | | `run_script_function` | **Core** | Execute function with parameters | -| `create_deployment` | Extended | Create new script deployment | | `list_deployments` | Extended | List all project deployments | -| `update_deployment` | Extended | Update deployment configuration | -| `delete_deployment` | Extended | Remove deployment | +| `manage_deployment` | Extended | Create, update, or delete script deployments | | `list_script_processes` | Extended | View recent executions and status | diff --git a/README_NEW.md b/README_NEW.md index 7debb00..9e01ba0 100644 --- a/README_NEW.md +++ b/README_NEW.md @@ -47,7 +47,7 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only ## 🛠 Tools Reference -### Gmail (11 tools) +### Gmail (10 tools) | Tool | Tier | Description | |------|------|-------------| @@ -60,39 +60,42 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `list_gmail_labels` | Extended | List all system and user labels | | `manage_gmail_label` | Extended | Create, update, delete labels | | `modify_gmail_message_labels` | Extended | Add/remove labels (archive, trash, etc.) | +| `manage_gmail_filter` | Extended | Create or delete Gmail filters | | `get_gmail_threads_content_batch` | Complete | Batch retrieve threads | | `batch_modify_gmail_message_labels` | Complete | Bulk label operations | -**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters`, `create_gmail_filter`, `delete_gmail_filter` +**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters` -### Google Drive (7 tools) +### Google Drive (10 tools) | Tool | Tier | Description | |------|------|-------------| | `search_drive_files` | Core | Search files with Drive query syntax or free text | | `get_drive_file_content` | Core | Read content from Docs, Sheets, Office files (.docx, .xlsx, .pptx) | +| `get_drive_file_download_url` | Core | Download Drive files to local disk | | `create_drive_file` | Core | Create files from content or URL (supports file://, http://, https://) | | `create_drive_folder` | Core | Create empty folders in Drive or shared drives | +| `import_to_google_doc` | Core | Import files (MD, DOCX, HTML, etc.) as Google Docs | +| `get_drive_shareable_link` | Core | Get shareable links for a file | | `list_drive_items` | Extended | List folder contents with shared drive support | +| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming | | `update_drive_file` | Extended | Update metadata, move between folders, star, trash | -| `get_drive_file_permissions` | Complete | Check sharing status and permissions | +| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership | +| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings | +| `get_drive_file_permissions` | Complete | Get detailed file permissions | | `check_drive_file_public_access` | Complete | Verify public link sharing for Docs image insertion | -**Also includes:** `get_drive_file_download_url` for generating download URLs - -### Google Calendar (5 tools) +### Google Calendar (3 tools) | Tool | Tier | Description | |------|------|-------------| | `list_calendars` | Core | List all accessible calendars | | `get_events` | Core | Query events by time range, search, or specific ID | -| `create_event` | Core | Create events with attendees, reminders, Google Meet, attachments | -| `modify_event` | Core | Update any event property including conferencing | -| `delete_event` | Extended | Remove events | +| `manage_event` | Core | Create, update, or delete calendar events | -**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders +**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders, Google Meet integration, attendees, attachments -### Google Docs (16 tools) +### Google Docs (14 tools) | Tool | Tier | Description | |------|------|-------------| @@ -103,6 +106,8 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `find_and_replace_doc` | Extended | Global find/replace with case matching | | `list_docs_in_folder` | Extended | List Docs in a specific folder | | `insert_doc_elements` | Extended | Add tables, lists, page breaks | +| `update_paragraph_style` | Extended | Apply heading styles, lists (bulleted/numbered with nesting), and paragraph formatting | +| `get_doc_as_markdown` | Extended | Export document as formatted Markdown with optional comments | | `export_doc_to_pdf` | Extended | Export to PDF and save to Drive | | `insert_doc_image` | Complete | Insert images from Drive or URLs | | `update_doc_headers_footers` | Complete | Modify headers/footers | @@ -110,10 +115,10 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `inspect_doc_structure` | Complete | Analyze document structure for safe insertion points | | `create_table_with_data` | Complete | Create and populate tables in one operation | | `debug_table_structure` | Complete | Debug table cell positions and content | +| `list_document_comments` | Complete | List all document comments | +| `manage_document_comment` | Complete | Create, reply to, or resolve comments | -**Comments:** `read_document_comments`, `create_document_comment`, `reply_to_document_comment`, `resolve_document_comment` - -### Google Sheets (13 tools) +### Google Sheets (9 tools) | Tool | Tier | Description | |------|------|-------------| @@ -124,13 +129,11 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `get_spreadsheet_info` | Extended | Get metadata, sheets, conditional formats | | `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size | | `create_sheet` | Complete | Add sheets to existing spreadsheets | -| `add_conditional_formatting` | Complete | Add boolean or gradient rules | -| `update_conditional_formatting` | Complete | Modify existing rules | -| `delete_conditional_formatting` | Complete | Remove formatting rules | +| `list_spreadsheet_comments` | Complete | List all spreadsheet comments | +| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments | +| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules | -**Comments:** `read_spreadsheet_comments`, `create_spreadsheet_comment`, `reply_to_spreadsheet_comment`, `resolve_spreadsheet_comment` - -### Google Slides (9 tools) +### Google Slides (7 tools) | Tool | Tier | Description | |------|------|-------------| @@ -139,8 +142,8 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `batch_update_presentation` | Extended | Apply multiple updates (create slides, shapes, etc.) | | `get_page` | Extended | Get specific slide details and elements | | `get_page_thumbnail` | Extended | Generate PNG thumbnails | - -**Comments:** `read_presentation_comments`, `create_presentation_comment`, `reply_to_presentation_comment`, `resolve_presentation_comment` +| `list_presentation_comments` | Complete | List all presentation comments | +| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments | ### Google Forms (6 tools) @@ -153,24 +156,18 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `get_form_response` | Complete | Get individual response details | | `batch_update_form` | Complete | Execute batch updates to forms (questions, items, settings) | -### Google Tasks (12 tools) +### Google Tasks (5 tools) | Tool | Tier | Description | |------|------|-------------| | `list_tasks` | Core | List tasks with filtering, subtask hierarchy preserved | | `get_task` | Core | Get task details | -| `create_task` | Core | Create tasks with notes, due dates, parent/sibling positioning | -| `update_task` | Core | Update task properties | -| `delete_task` | Extended | Remove tasks | +| `manage_task` | Core | Create, update, delete, or move tasks | | `list_task_lists` | Complete | List all task lists | | `get_task_list` | Complete | Get task list details | -| `create_task_list` | Complete | Create new task lists | -| `update_task_list` | Complete | Rename task lists | -| `delete_task_list` | Complete | Delete task lists (and all tasks) | -| `move_task` | Complete | Reposition or move between lists | -| `clear_completed_tasks` | Complete | Hide completed tasks | +| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks | -### Google Apps Script (11 tools) +### Google Apps Script (9 tools) | Tool | Tier | Description | |------|------|-------------| @@ -180,16 +177,27 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `create_script_project` | Core | Create new standalone or bound project | | `update_script_content` | Core | Update or create script files | | `run_script_function` | Core | Execute function with parameters | -| `create_deployment` | Extended | Create new script deployment | | `list_deployments` | Extended | List all project deployments | -| `update_deployment` | Extended | Update deployment configuration | -| `delete_deployment` | Extended | Remove deployment | +| `manage_deployment` | Extended | Create, update, or delete script deployments | | `list_script_processes` | Extended | View recent executions and status | **Enables:** Cross-app automation, persistent workflows, custom business logic execution, script development and debugging **Note:** Trigger management is not currently supported via MCP tools. +### Google Contacts (7 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_contacts` | Core | Search contacts by name, email, phone | +| `get_contact` | Core | Retrieve detailed contact info | +| `list_contacts` | Core | List contacts with pagination | +| `manage_contact` | Core | Create, update, or delete contacts | +| `list_contact_groups` | Extended | List contact groups/labels | +| `get_contact_group` | Extended | Get group details with members | +| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts | +| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership | + ### Google Chat (4 tools) | Tool | Tier | Description | @@ -199,12 +207,11 @@ export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only | `search_messages` | Core | Search across chat history | | `list_spaces` | Extended | List rooms and DMs | -### Google Custom Search (3 tools) +### Google Custom Search (2 tools) | Tool | Tier | Description | |------|------|-------------| -| `search_custom` | Core | Web search with filters (date, file type, language, safe search) | -| `search_custom_siterestrict` | Extended | Search within specific domains | +| `search_custom` | Core | Web search with filters (date, file type, language, safe search, site restrictions via sites parameter) | | `get_search_engine_info` | Complete | Get search engine metadata | **Requires:** `GOOGLE_PSE_API_KEY` and `GOOGLE_PSE_ENGINE_ID` environment variables @@ -219,7 +226,7 @@ Choose a tier based on your needs: |------|-------|----------| | **Core** | ~30 | Essential operations: search, read, create, send | | **Extended** | ~50 | Core + management: labels, folders, batch ops | -| **Complete** | ~80 | Full API: comments, headers, admin functions | +| **Complete** | 111 | Full API: comments, headers, admin functions | ```bash uvx workspace-mcp --tool-tier core # Start minimal diff --git a/auth/google_auth.py b/auth/google_auth.py index 942d2f5..93e438e 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -295,6 +295,7 @@ def create_oauth_flow( redirect_uri: str, state: Optional[str] = None, code_verifier: Optional[str] = None, + autogenerate_code_verifier: bool = True, ) -> Flow: """Creates an OAuth flow using environment variables or client secrets file.""" flow_kwargs = { @@ -306,6 +307,12 @@ def create_oauth_flow( flow_kwargs["code_verifier"] = code_verifier # Preserve the original verifier when re-creating the flow in callback. flow_kwargs["autogenerate_code_verifier"] = False + else: + # Generate PKCE code verifier for the initial auth flow. + # google-auth-oauthlib's from_client_* helpers pass + # autogenerate_code_verifier=None unless explicitly provided, which + # prevents Flow from generating and storing a code_verifier. + flow_kwargs["autogenerate_code_verifier"] = autogenerate_code_verifier # Try environment variables first env_config = load_client_secrets_from_env() @@ -520,6 +527,7 @@ def handle_auth_callback( redirect_uri=redirect_uri, state=state, code_verifier=state_info.get("code_verifier"), + autogenerate_code_verifier=False, ) # Exchange the authorization code for credentials diff --git a/core/comments.py b/core/comments.py index c07d878..844bdda 100644 --- a/core/comments.py +++ b/core/comments.py @@ -7,7 +7,7 @@ All Google Workspace apps (Docs, Sheets, Slides) use the Drive API for comment o import logging import asyncio - +from typing import Optional from auth.service_decorator import require_google_service from core.server import server @@ -16,6 +16,38 @@ from core.utils import handle_http_errors logger = logging.getLogger(__name__) +async def _manage_comment_dispatch( + service, + app_name: str, + file_id: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, +) -> str: + """Route comment management actions to the appropriate implementation.""" + action_lower = action.lower().strip() + if action_lower == "create": + if not comment_content: + raise ValueError("comment_content is required for create action") + return await _create_comment_impl(service, app_name, file_id, comment_content) + elif action_lower == "reply": + if not comment_id or not comment_content: + raise ValueError( + "comment_id and comment_content are required for reply action" + ) + return await _reply_to_comment_impl( + service, app_name, file_id, comment_id, comment_content + ) + elif action_lower == "resolve": + if not comment_id: + raise ValueError("comment_id is required for resolve action") + return await _resolve_comment_impl(service, app_name, file_id, comment_id) + else: + raise ValueError( + f"Invalid action '{action_lower}'. Must be 'create', 'reply', or 'resolve'." + ) + + def create_comment_tools(app_name: str, file_id_param: str): """ Factory function to create comment management tools for a specific Google Workspace app. @@ -25,165 +57,114 @@ def create_comment_tools(app_name: str, file_id_param: str): file_id_param: Parameter name for the file ID (e.g., "document_id", "spreadsheet_id", "presentation_id") Returns: - Dict containing the four comment management functions with unique names + Dict containing the comment management functions with unique names """ - # Create unique function names based on the app type - read_func_name = f"read_{app_name}_comments" - create_func_name = f"create_{app_name}_comment" - reply_func_name = f"reply_to_{app_name}_comment" - resolve_func_name = f"resolve_{app_name}_comment" + # --- Consolidated tools --- + list_func_name = f"list_{app_name}_comments" + manage_func_name = f"manage_{app_name}_comment" - # Create functions without decorators first, then apply decorators with proper names if file_id_param == "document_id": @require_google_service("drive", "drive_read") - @handle_http_errors(read_func_name, service_type="drive") - async def read_comments( + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( service, user_google_email: str, document_id: str ) -> str: - """Read all comments from a Google Document.""" + """List all comments from a Google Document.""" return await _read_comments_impl(service, app_name, document_id) @require_google_service("drive", "drive_file") - @handle_http_errors(create_func_name, service_type="drive") - async def create_comment( - service, user_google_email: str, document_id: str, comment_content: str - ) -> str: - """Create a new comment on a Google Document.""" - return await _create_comment_impl( - service, app_name, document_id, comment_content - ) - - @require_google_service("drive", "drive_file") - @handle_http_errors(reply_func_name, service_type="drive") - async def reply_to_comment( + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( service, user_google_email: str, document_id: str, - comment_id: str, - reply_content: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, ) -> str: - """Reply to a specific comment in a Google Document.""" - return await _reply_to_comment_impl( - service, app_name, document_id, comment_id, reply_content - ) + """Manage comments on a Google Document. - @require_google_service("drive", "drive_file") - @handle_http_errors(resolve_func_name, service_type="drive") - async def resolve_comment( - service, user_google_email: str, document_id: str, comment_id: str - ) -> str: - """Resolve a comment in a Google Document.""" - return await _resolve_comment_impl( - service, app_name, document_id, comment_id + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, document_id, action, comment_content, comment_id ) elif file_id_param == "spreadsheet_id": @require_google_service("drive", "drive_read") - @handle_http_errors(read_func_name, service_type="drive") - async def read_comments( + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( service, user_google_email: str, spreadsheet_id: str ) -> str: - """Read all comments from a Google Spreadsheet.""" + """List all comments from a Google Spreadsheet.""" return await _read_comments_impl(service, app_name, spreadsheet_id) @require_google_service("drive", "drive_file") - @handle_http_errors(create_func_name, service_type="drive") - async def create_comment( - service, user_google_email: str, spreadsheet_id: str, comment_content: str - ) -> str: - """Create a new comment on a Google Spreadsheet.""" - return await _create_comment_impl( - service, app_name, spreadsheet_id, comment_content - ) - - @require_google_service("drive", "drive_file") - @handle_http_errors(reply_func_name, service_type="drive") - async def reply_to_comment( + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( service, user_google_email: str, spreadsheet_id: str, - comment_id: str, - reply_content: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, ) -> str: - """Reply to a specific comment in a Google Spreadsheet.""" - return await _reply_to_comment_impl( - service, app_name, spreadsheet_id, comment_id, reply_content - ) + """Manage comments on a Google Spreadsheet. - @require_google_service("drive", "drive_file") - @handle_http_errors(resolve_func_name, service_type="drive") - async def resolve_comment( - service, user_google_email: str, spreadsheet_id: str, comment_id: str - ) -> str: - """Resolve a comment in a Google Spreadsheet.""" - return await _resolve_comment_impl( - service, app_name, spreadsheet_id, comment_id + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, spreadsheet_id, action, comment_content, comment_id ) elif file_id_param == "presentation_id": @require_google_service("drive", "drive_read") - @handle_http_errors(read_func_name, service_type="drive") - async def read_comments( + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( service, user_google_email: str, presentation_id: str ) -> str: - """Read all comments from a Google Presentation.""" + """List all comments from a Google Presentation.""" return await _read_comments_impl(service, app_name, presentation_id) @require_google_service("drive", "drive_file") - @handle_http_errors(create_func_name, service_type="drive") - async def create_comment( - service, user_google_email: str, presentation_id: str, comment_content: str - ) -> str: - """Create a new comment on a Google Presentation.""" - return await _create_comment_impl( - service, app_name, presentation_id, comment_content - ) - - @require_google_service("drive", "drive_file") - @handle_http_errors(reply_func_name, service_type="drive") - async def reply_to_comment( + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( service, user_google_email: str, presentation_id: str, - comment_id: str, - reply_content: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, ) -> str: - """Reply to a specific comment in a Google Presentation.""" - return await _reply_to_comment_impl( - service, app_name, presentation_id, comment_id, reply_content + """Manage comments on a Google Presentation. + + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, presentation_id, action, comment_content, comment_id ) - @require_google_service("drive", "drive_file") - @handle_http_errors(resolve_func_name, service_type="drive") - async def resolve_comment( - service, user_google_email: str, presentation_id: str, comment_id: str - ) -> str: - """Resolve a comment in a Google Presentation.""" - return await _resolve_comment_impl( - service, app_name, presentation_id, comment_id - ) - - # Set the proper function names and register with server - read_comments.__name__ = read_func_name - create_comment.__name__ = create_func_name - reply_to_comment.__name__ = reply_func_name - resolve_comment.__name__ = resolve_func_name - - # Register tools with the server using the proper names - server.tool()(read_comments) - server.tool()(create_comment) - server.tool()(reply_to_comment) - server.tool()(resolve_comment) + list_comments.__name__ = list_func_name + manage_comment.__name__ = manage_func_name + server.tool()(list_comments) + server.tool()(manage_comment) return { - "read_comments": read_comments, - "create_comment": create_comment, - "reply_to_comment": reply_to_comment, - "resolve_comment": resolve_comment, + "list_comments": list_comments, + "manage_comment": manage_comment, } diff --git a/core/tool_tiers.yaml b/core/tool_tiers.yaml index ca929d7..666833b 100644 --- a/core/tool_tiers.yaml +++ b/core/tool_tiers.yaml @@ -13,8 +13,7 @@ gmail: - manage_gmail_label - draft_gmail_message - list_gmail_filters - - create_gmail_filter - - delete_gmail_filter + - manage_gmail_filter complete: - get_gmail_threads_content_batch @@ -29,16 +28,12 @@ drive: - create_drive_file - create_drive_folder - import_to_google_doc - - share_drive_file - get_drive_shareable_link extended: - list_drive_items - copy_drive_file - update_drive_file - - update_drive_permission - - remove_drive_permission - - transfer_drive_ownership - - batch_share_drive_file + - manage_drive_access - set_drive_file_permissions complete: - get_drive_file_permissions @@ -48,10 +43,8 @@ calendar: core: - list_calendars - get_events - - create_event - - modify_event + - manage_event extended: - - delete_event - query_freebusy complete: [] @@ -75,10 +68,8 @@ docs: - inspect_doc_structure - create_table_with_data - debug_table_structure - - read_document_comments - - create_document_comment - - reply_to_document_comment - - resolve_document_comment + - list_document_comments + - manage_document_comment sheets: core: @@ -91,10 +82,9 @@ sheets: - format_sheet_range complete: - create_sheet - - read_spreadsheet_comments - - create_spreadsheet_comment - - reply_to_spreadsheet_comment - - resolve_spreadsheet_comment + - list_spreadsheet_comments + - manage_spreadsheet_comment + - manage_conditional_formatting chat: core: @@ -127,53 +117,37 @@ slides: - get_page - get_page_thumbnail complete: - - read_presentation_comments - - create_presentation_comment - - reply_to_presentation_comment - - resolve_presentation_comment + - list_presentation_comments + - manage_presentation_comment tasks: core: - get_task - list_tasks - - create_task - - update_task - extended: - - delete_task + - manage_task + extended: [] complete: - list_task_lists - get_task_list - - create_task_list - - update_task_list - - delete_task_list - - move_task - - clear_completed_tasks + - manage_task_list contacts: core: - search_contacts - get_contact - list_contacts - - create_contact + - manage_contact extended: - - update_contact - - delete_contact - list_contact_groups - get_contact_group complete: - - batch_create_contacts - - batch_update_contacts - - batch_delete_contacts - - create_contact_group - - update_contact_group - - delete_contact_group - - modify_contact_group_members + - manage_contacts_batch + - manage_contact_group search: core: - search_custom - extended: - - search_custom_siterestrict + extended: [] complete: - get_search_engine_info @@ -187,10 +161,8 @@ appscript: - run_script_function - generate_trigger_code extended: - - create_deployment + - manage_deployment - list_deployments - - update_deployment - - delete_deployment - delete_script_project - list_versions - create_version diff --git a/core/utils.py b/core/utils.py index c5c61ee..ee91fb3 100644 --- a/core/utils.py +++ b/core/utils.py @@ -2,7 +2,6 @@ import io import logging import os import zipfile -import xml.etree.ElementTree as ET import ssl import asyncio import functools @@ -10,6 +9,8 @@ import functools from pathlib import Path from typing import List, Optional +from defusedxml import ElementTree as ET + from googleapiclient.errors import HttpError from .api_enablement import get_api_enablement_message from auth.google_auth import GoogleAuthenticationError @@ -226,7 +227,7 @@ def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]: """ Very light-weight XML scraper for Word, Excel, PowerPoint files. Returns plain-text if something readable is found, else None. - No external deps – just std-lib zipfile + ElementTree. + Uses zipfile + defusedxml.ElementTree. """ shared_strings: List[str] = [] ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" diff --git a/gappsscript/apps_script_tools.py b/gappsscript/apps_script_tools.py index 20cba10..eaf491d 100644 --- a/gappsscript/apps_script_tools.py +++ b/gappsscript/apps_script_tools.py @@ -464,31 +464,57 @@ async def _create_deployment_impl( @server.tool() -@handle_http_errors("create_deployment", service_type="script") +@handle_http_errors("manage_deployment", service_type="script") @require_google_service("script", "script_deployments") -async def create_deployment( +async def manage_deployment( service: Any, user_google_email: str, + action: str, script_id: str, - description: str, + deployment_id: Optional[str] = None, + description: Optional[str] = None, version_description: Optional[str] = None, ) -> str: """ - Creates a new deployment of the script. + Manages Apps Script deployments. Supports creating, updating, and deleting deployments. Args: service: Injected Google API service client user_google_email: User's email address + action: Action to perform - "create", "update", or "delete" script_id: The script project ID - description: Deployment description - version_description: Optional version description + deployment_id: The deployment ID (required for update and delete) + description: Deployment description (required for create and update) + version_description: Optional version description (for create only) Returns: - str: Formatted string with deployment details + str: Formatted string with deployment details or confirmation """ - return await _create_deployment_impl( - service, user_google_email, script_id, description, version_description - ) + action = action.lower().strip() + if action == "create": + if description is None or description.strip() == "": + raise ValueError("description is required for create action") + return await _create_deployment_impl( + service, user_google_email, script_id, description, version_description + ) + elif action == "update": + if not deployment_id: + raise ValueError("deployment_id is required for update action") + if description is None or description.strip() == "": + raise ValueError("description is required for update action") + return await _update_deployment_impl( + service, user_google_email, script_id, deployment_id, description + ) + elif action == "delete": + if not deployment_id: + raise ValueError("deployment_id is required for delete action") + return await _delete_deployment_impl( + service, user_google_email, script_id, deployment_id + ) + else: + raise ValueError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) async def _list_deployments_impl( @@ -578,34 +604,6 @@ async def _update_deployment_impl( return "\n".join(output) -@server.tool() -@handle_http_errors("update_deployment", service_type="script") -@require_google_service("script", "script_deployments") -async def update_deployment( - service: Any, - user_google_email: str, - script_id: str, - deployment_id: str, - description: Optional[str] = None, -) -> str: - """ - Updates an existing deployment configuration. - - Args: - service: Injected Google API service client - user_google_email: User's email address - script_id: The script project ID - deployment_id: The deployment ID to update - description: Optional new description - - Returns: - str: Formatted string confirming update - """ - return await _update_deployment_impl( - service, user_google_email, script_id, deployment_id, description - ) - - async def _delete_deployment_impl( service: Any, user_google_email: str, @@ -630,32 +628,6 @@ async def _delete_deployment_impl( return output -@server.tool() -@handle_http_errors("delete_deployment", service_type="script") -@require_google_service("script", "script_deployments") -async def delete_deployment( - service: Any, - user_google_email: str, - script_id: str, - deployment_id: str, -) -> str: - """ - Deletes a deployment. - - Args: - service: Injected Google API service client - user_google_email: User's email address - script_id: The script project ID - deployment_id: The deployment ID to delete - - Returns: - str: Confirmation message - """ - return await _delete_deployment_impl( - service, user_google_email, script_id, deployment_id - ) - - async def _list_script_processes_impl( service: Any, user_google_email: str, diff --git a/gcalendar/calendar_tools.py b/gcalendar/calendar_tools.py index 4bc51f7..13da74c 100644 --- a/gcalendar/calendar_tools.py +++ b/gcalendar/calendar_tools.py @@ -534,10 +534,14 @@ async def get_events( return text_output -@server.tool() -@handle_http_errors("create_event", service_type="calendar") -@require_google_service("calendar", "calendar_events") -async def create_event( +# --------------------------------------------------------------------------- +# Internal implementation functions for event create/modify/delete. +# These are called by both the consolidated ``manage_event`` tool and the +# legacy single-action tools. +# --------------------------------------------------------------------------- + + +async def _create_event_impl( service, user_google_email: str, summary: str, @@ -558,32 +562,7 @@ async def create_event( guests_can_invite_others: Optional[bool] = None, guests_can_see_other_guests: Optional[bool] = None, ) -> str: - """ - Creates a new event. - - Args: - user_google_email (str): The user's Google email address. Required. - summary (str): Event title. - start_time (str): Start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day). - end_time (str): End time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day). - calendar_id (str): Calendar ID (default: 'primary'). - description (Optional[str]): Event description. - location (Optional[str]): Event location. - attendees (Optional[List[str]]): Attendee email addresses. - timezone (Optional[str]): Timezone (e.g., "America/New_York"). - attachments (Optional[List[str]]): List of Google Drive file URLs or IDs to attach to the event. - add_google_meet (bool): Whether to add a Google Meet video conference to the event. Defaults to False. - reminders (Optional[Union[str, List[Dict[str, Any]]]]): JSON string or list of reminder objects. Each should have 'method' ("popup" or "email") and 'minutes' (0-40320). Max 5 reminders. Example: '[{"method": "popup", "minutes": 15}]' or [{"method": "popup", "minutes": 15}] - use_default_reminders (bool): Whether to use calendar's default reminders. If False, uses custom reminders. Defaults to True. - transparency (Optional[str]): Event transparency for busy/free status. "opaque" shows as Busy (default), "transparent" shows as Available/Free. Defaults to None (uses Google Calendar default). - visibility (Optional[str]): Event visibility. "default" uses calendar default, "public" is visible to all, "private" is visible only to attendees, "confidential" is same as private (legacy). Defaults to None (uses Google Calendar default). - guests_can_modify (Optional[bool]): Whether attendees other than the organizer can modify the event. Defaults to None (uses Google Calendar default of False). - guests_can_invite_others (Optional[bool]): Whether attendees other than the organizer can invite others to the event. Defaults to None (uses Google Calendar default of True). - guests_can_see_other_guests (Optional[bool]): Whether attendees other than the organizer can see who the event's attendees are. Defaults to None (uses Google Calendar default of True). - - Returns: - str: Confirmation message of the successful event creation with event link. - """ + """Internal implementation for creating a calendar event.""" logger.info( f"[create_event] Invoked. Email: '{user_google_email}', Summary: {summary}" ) @@ -809,10 +788,7 @@ def _normalize_attendees( return normalized if normalized else None -@server.tool() -@handle_http_errors("modify_event", service_type="calendar") -@require_google_service("calendar", "calendar_events") -async def modify_event( +async def _modify_event_impl( service, user_google_email: str, event_id: str, @@ -834,33 +810,7 @@ async def modify_event( guests_can_invite_others: Optional[bool] = None, guests_can_see_other_guests: Optional[bool] = None, ) -> str: - """ - Modifies an existing event. - - Args: - user_google_email (str): The user's Google email address. Required. - event_id (str): The ID of the event to modify. - calendar_id (str): Calendar ID (default: 'primary'). - summary (Optional[str]): New event title. - start_time (Optional[str]): New start time (RFC3339, e.g., "2023-10-27T10:00:00-07:00" or "2023-10-27" for all-day). - end_time (Optional[str]): New end time (RFC3339, e.g., "2023-10-27T11:00:00-07:00" or "2023-10-28" for all-day). - description (Optional[str]): New event description. - location (Optional[str]): New event location. - attendees (Optional[Union[List[str], List[Dict[str, Any]]]]): Attendees as email strings or objects with metadata. Supports: ["email@example.com"] or [{"email": "email@example.com", "responseStatus": "accepted", "organizer": true, "optional": true}]. When using objects, existing metadata (responseStatus, organizer, optional) is preserved. New attendees default to responseStatus="needsAction". - timezone (Optional[str]): New timezone (e.g., "America/New_York"). - add_google_meet (Optional[bool]): Whether to add or remove Google Meet video conference. If True, adds Google Meet; if False, removes it; if None, leaves unchanged. - reminders (Optional[Union[str, List[Dict[str, Any]]]]): JSON string or list of reminder objects to replace existing reminders. Each should have 'method' ("popup" or "email") and 'minutes' (0-40320). Max 5 reminders. Example: '[{"method": "popup", "minutes": 15}]' or [{"method": "popup", "minutes": 15}] - use_default_reminders (Optional[bool]): Whether to use calendar's default reminders. If specified, overrides current reminder settings. - transparency (Optional[str]): Event transparency for busy/free status. "opaque" shows as Busy, "transparent" shows as Available/Free. If None, preserves existing transparency setting. - visibility (Optional[str]): Event visibility. "default" uses calendar default, "public" is visible to all, "private" is visible only to attendees, "confidential" is same as private (legacy). If None, preserves existing visibility setting. - color_id (Optional[str]): Event color ID (1-11). If None, preserves existing color. - guests_can_modify (Optional[bool]): Whether attendees other than the organizer can modify the event. If None, preserves existing setting. - guests_can_invite_others (Optional[bool]): Whether attendees other than the organizer can invite others to the event. If None, preserves existing setting. - guests_can_see_other_guests (Optional[bool]): Whether attendees other than the organizer can see who the event's attendees are. If None, preserves existing setting. - - Returns: - str: Confirmation message of the successful event modification with event link. - """ + """Internal implementation for modifying a calendar event.""" logger.info( f"[modify_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}" ) @@ -1075,23 +1025,13 @@ async def modify_event( return confirmation_message -@server.tool() -@handle_http_errors("delete_event", service_type="calendar") -@require_google_service("calendar", "calendar_events") -async def delete_event( - service, user_google_email: str, event_id: str, calendar_id: str = "primary" +async def _delete_event_impl( + service, + user_google_email: str, + event_id: str, + calendar_id: str = "primary", ) -> str: - """ - Deletes an existing event. - - Args: - user_google_email (str): The user's Google email address. Required. - event_id (str): The ID of the event to delete. - calendar_id (str): Calendar ID (default: 'primary'). - - Returns: - str: Confirmation message of the successful event deletion. - """ + """Internal implementation for deleting a calendar event.""" logger.info( f"[delete_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}" ) @@ -1133,6 +1073,141 @@ async def delete_event( return confirmation_message +# --------------------------------------------------------------------------- +# Consolidated event management tool +# --------------------------------------------------------------------------- + + +@server.tool() +@handle_http_errors("manage_event", service_type="calendar") +@require_google_service("calendar", "calendar_events") +async def manage_event( + service, + user_google_email: str, + action: str, + summary: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + event_id: Optional[str] = None, + calendar_id: str = "primary", + description: Optional[str] = None, + location: Optional[str] = None, + attendees: Optional[Union[List[str], List[Dict[str, Any]]]] = None, + timezone: Optional[str] = None, + attachments: Optional[List[str]] = None, + add_google_meet: Optional[bool] = None, + reminders: Optional[Union[str, List[Dict[str, Any]]]] = None, + use_default_reminders: Optional[bool] = None, + transparency: Optional[str] = None, + visibility: Optional[str] = None, + color_id: Optional[str] = None, + guests_can_modify: Optional[bool] = None, + guests_can_invite_others: Optional[bool] = None, + guests_can_see_other_guests: Optional[bool] = None, +) -> str: + """ + Manages calendar events. Supports creating, updating, and deleting events. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): Action to perform - "create", "update", or "delete". + summary (Optional[str]): Event title (required for create). + start_time (Optional[str]): Start time in RFC3339 format (required for create). + end_time (Optional[str]): End time in RFC3339 format (required for create). + event_id (Optional[str]): Event ID (required for update and delete). + calendar_id (str): Calendar ID (default: 'primary'). + description (Optional[str]): Event description. + location (Optional[str]): Event location. + attendees (Optional[Union[List[str], List[Dict[str, Any]]]]): Attendee email addresses or objects. + timezone (Optional[str]): Timezone (e.g., "America/New_York"). + attachments (Optional[List[str]]): List of Google Drive file URLs or IDs to attach. + add_google_meet (Optional[bool]): Whether to add/remove Google Meet. + reminders (Optional[Union[str, List[Dict[str, Any]]]]): Custom reminder objects. + use_default_reminders (Optional[bool]): Whether to use default reminders. + transparency (Optional[str]): "opaque" (busy) or "transparent" (free). + visibility (Optional[str]): "default", "public", "private", or "confidential". + color_id (Optional[str]): Event color ID (1-11, update only). + guests_can_modify (Optional[bool]): Whether attendees can modify. + guests_can_invite_others (Optional[bool]): Whether attendees can invite others. + guests_can_see_other_guests (Optional[bool]): Whether attendees can see other guests. + + Returns: + str: Confirmation message with event details. + """ + action_lower = action.lower().strip() + if action_lower == "create": + if not summary or not start_time or not end_time: + raise ValueError( + "summary, start_time, and end_time are required for create action" + ) + return await _create_event_impl( + service=service, + user_google_email=user_google_email, + summary=summary, + start_time=start_time, + end_time=end_time, + calendar_id=calendar_id, + description=description, + location=location, + attendees=attendees, + timezone=timezone, + attachments=attachments, + add_google_meet=add_google_meet or False, + reminders=reminders, + use_default_reminders=use_default_reminders + if use_default_reminders is not None + else True, + transparency=transparency, + visibility=visibility, + guests_can_modify=guests_can_modify, + guests_can_invite_others=guests_can_invite_others, + guests_can_see_other_guests=guests_can_see_other_guests, + ) + elif action_lower == "update": + if not event_id: + raise ValueError("event_id is required for update action") + return await _modify_event_impl( + service=service, + user_google_email=user_google_email, + event_id=event_id, + calendar_id=calendar_id, + summary=summary, + start_time=start_time, + end_time=end_time, + description=description, + location=location, + attendees=attendees, + timezone=timezone, + add_google_meet=add_google_meet, + reminders=reminders, + use_default_reminders=use_default_reminders, + transparency=transparency, + visibility=visibility, + color_id=color_id, + guests_can_modify=guests_can_modify, + guests_can_invite_others=guests_can_invite_others, + guests_can_see_other_guests=guests_can_see_other_guests, + ) + elif action_lower == "delete": + if not event_id: + raise ValueError("event_id is required for delete action") + return await _delete_event_impl( + service=service, + user_google_email=user_google_email, + event_id=event_id, + calendar_id=calendar_id, + ) + else: + raise ValueError( + f"Invalid action '{action_lower}'. Must be 'create', 'update', or 'delete'." + ) + + +# --------------------------------------------------------------------------- +# Legacy single-action tools (deprecated -- prefer ``manage_event``) +# --------------------------------------------------------------------------- + + @server.tool() @handle_http_errors("query_freebusy", is_read_only=True, service_type="calendar") @require_google_service("calendar", "calendar_read") diff --git a/gcontacts/contacts_tools.py b/gcontacts/contacts_tools.py index 38e08f8..ab04053 100644 --- a/gcontacts/contacts_tools.py +++ b/gcontacts/contacts_tools.py @@ -13,7 +13,7 @@ from mcp import Resource from auth.service_decorator import require_google_service from core.server import server -from core.utils import handle_http_errors +from core.utils import UserInputError, handle_http_errors logger = logging.getLogger(__name__) @@ -249,48 +249,44 @@ async def list_contacts( """ logger.info(f"[list_contacts] Invoked. Email: '{user_google_email}'") - try: - params: Dict[str, Any] = { - "resourceName": "people/me", - "personFields": DEFAULT_PERSON_FIELDS, - "pageSize": min(page_size, 1000), - } + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 1000) - if page_token: - params["pageToken"] = page_token - if sort_order: - params["sortOrder"] = sort_order + params: Dict[str, Any] = { + "resourceName": "people/me", + "personFields": DEFAULT_PERSON_FIELDS, + "pageSize": page_size, + } - result = await asyncio.to_thread( - service.people().connections().list(**params).execute - ) + if page_token: + params["pageToken"] = page_token + if sort_order: + params["sortOrder"] = sort_order - connections = result.get("connections", []) - next_page_token = result.get("nextPageToken") - total_people = result.get("totalPeople", len(connections)) + result = await asyncio.to_thread( + service.people().connections().list(**params).execute + ) - if not connections: - return f"No contacts found for {user_google_email}." + connections = result.get("connections", []) + next_page_token = result.get("nextPageToken") + total_people = result.get("totalPeople", len(connections)) - response = f"Contacts for {user_google_email} ({len(connections)} of {total_people}):\n\n" + if not connections: + return f"No contacts found for {user_google_email}." - for person in connections: - response += _format_contact(person) + "\n\n" + response = ( + f"Contacts for {user_google_email} ({len(connections)} of {total_people}):\n\n" + ) - if next_page_token: - response += f"Next page token: {next_page_token}" + for person in connections: + response += _format_contact(person) + "\n\n" - logger.info(f"Found {len(connections)} contacts for {user_google_email}") - return response + if next_page_token: + response += f"Next page token: {next_page_token}" - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Found {len(connections)} contacts for {user_google_email}") + return response @server.tool() @@ -321,31 +317,17 @@ async def get_contact( f"[get_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}" ) - try: - person = await asyncio.to_thread( - service.people() - .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS) - .execute - ) + person = await asyncio.to_thread( + service.people() + .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS) + .execute + ) - response = f"Contact Details for {user_google_email}:\n\n" - response += _format_contact(person, detailed=True) + response = f"Contact Details for {user_google_email}:\n\n" + response += _format_contact(person, detailed=True) - logger.info(f"Retrieved contact {resource_name} for {user_google_email}") - return response - - except HttpError as error: - if error.resp.status == 404: - message = f"Contact not found: {contact_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Retrieved contact {resource_name} for {user_google_email}") + return response @server.tool() @@ -372,52 +354,48 @@ async def search_contacts( f"[search_contacts] Invoked. Email: '{user_google_email}', Query: '{query}'" ) - try: - # Warm up the search cache if needed - await _warmup_search_cache(service, user_google_email) + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 30) - result = await asyncio.to_thread( - service.people() - .searchContacts( - query=query, - readMask=DEFAULT_PERSON_FIELDS, - pageSize=min(page_size, 30), - ) - .execute + # Warm up the search cache if needed + await _warmup_search_cache(service, user_google_email) + + result = await asyncio.to_thread( + service.people() + .searchContacts( + query=query, + readMask=DEFAULT_PERSON_FIELDS, + pageSize=page_size, ) + .execute + ) - results = result.get("results", []) + results = result.get("results", []) - if not results: - return f"No contacts found matching '{query}' for {user_google_email}." + if not results: + return f"No contacts found matching '{query}' for {user_google_email}." - response = f"Search Results for '{query}' ({len(results)} found):\n\n" + response = f"Search Results for '{query}' ({len(results)} found):\n\n" - for item in results: - person = item.get("person", {}) - response += _format_contact(person) + "\n\n" + for item in results: + person = item.get("person", {}) + response += _format_contact(person) + "\n\n" - logger.info( - f"Found {len(results)} contacts matching '{query}' for {user_google_email}" - ) - return response - - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info( + f"Found {len(results)} contacts matching '{query}' for {user_google_email}" + ) + return response @server.tool() @require_google_service("people", "contacts") -@handle_http_errors("create_contact", service_type="people") -async def create_contact( +@handle_http_errors("manage_contact", service_type="people") +async def manage_contact( service: Resource, user_google_email: str, + action: str, + contact_id: Optional[str] = None, given_name: Optional[str] = None, family_name: Optional[str] = None, email: Optional[str] = None, @@ -427,26 +405,35 @@ async def create_contact( notes: Optional[str] = None, ) -> str: """ - Create a new contact. + Create, update, or delete a contact. Consolidated tool replacing create_contact, + update_contact, and delete_contact. Args: user_google_email (str): The user's Google email address. Required. - given_name (Optional[str]): First name. - family_name (Optional[str]): Last name. - email (Optional[str]): Email address. - phone (Optional[str]): Phone number. - organization (Optional[str]): Company/organization name. - job_title (Optional[str]): Job title. - notes (Optional[str]): Additional notes. + action (str): The action to perform: "create", "update", or "delete". + contact_id (Optional[str]): The contact ID. Required for "update" and "delete" actions. + given_name (Optional[str]): First name (for create/update). + family_name (Optional[str]): Last name (for create/update). + email (Optional[str]): Email address (for create/update). + phone (Optional[str]): Phone number (for create/update). + organization (Optional[str]): Company/organization name (for create/update). + job_title (Optional[str]): Job title (for create/update). + notes (Optional[str]): Additional notes (for create/update). Returns: - str: Confirmation with the new contact's details. + str: Result of the action performed. """ + action = action.lower().strip() + if action not in ("create", "update", "delete"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) + logger.info( - f"[create_contact] Invoked. Email: '{user_google_email}', Name: '{given_name} {family_name}'" + f"[manage_contact] Invoked. Action: '{action}', Email: '{user_google_email}'" ) - try: + if action == "create": body = _build_person_body( given_name=given_name, family_name=family_name, @@ -458,7 +445,7 @@ async def create_contact( ) if not body: - raise Exception( + raise UserInputError( "At least one field (name, email, phone, etc.) must be provided." ) @@ -471,69 +458,22 @@ async def create_contact( response = f"Contact Created for {user_google_email}:\n\n" response += _format_contact(result, detailed=True) - contact_id = result.get("resourceName", "").replace("people/", "") - logger.info(f"Created contact {contact_id} for {user_google_email}") + created_id = result.get("resourceName", "").replace("people/", "") + logger.info(f"Created contact {created_id} for {user_google_email}") return response - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + # update and delete both require contact_id + if not contact_id: + raise UserInputError(f"contact_id is required for '{action}' action.") - -# ============================================================================= -# Extended Tier Tools -# ============================================================================= - - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("update_contact", service_type="people") -async def update_contact( - service: Resource, - user_google_email: str, - contact_id: str, - given_name: Optional[str] = None, - family_name: Optional[str] = None, - email: Optional[str] = None, - phone: Optional[str] = None, - organization: Optional[str] = None, - job_title: Optional[str] = None, - notes: Optional[str] = None, -) -> str: - """ - Update an existing contact. Note: This replaces fields, not merges them. - - Args: - user_google_email (str): The user's Google email address. Required. - contact_id (str): The contact ID to update. - given_name (Optional[str]): New first name. - family_name (Optional[str]): New last name. - email (Optional[str]): New email address. - phone (Optional[str]): New phone number. - organization (Optional[str]): New company/organization name. - job_title (Optional[str]): New job title. - notes (Optional[str]): New notes. - - Returns: - str: Confirmation with updated contact details. - """ # Normalize resource name if not contact_id.startswith("people/"): resource_name = f"people/{contact_id}" else: resource_name = contact_id - logger.info( - f"[update_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}" - ) - - try: - # First fetch the contact to get the etag + if action == "update": + # Fetch the contact to get the etag current = await asyncio.to_thread( service.people() .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS) @@ -544,7 +484,6 @@ async def update_contact( if not etag: raise Exception("Unable to get contact etag for update.") - # Build update body body = _build_person_body( given_name=given_name, family_name=family_name, @@ -556,13 +495,12 @@ async def update_contact( ) if not body: - raise Exception( + raise UserInputError( "At least one field (name, email, phone, etc.) must be provided." ) body["etag"] = etag - # Determine which fields to update update_person_fields = [] if "names" in body: update_person_fields.append("names") @@ -594,70 +532,19 @@ async def update_contact( logger.info(f"Updated contact {resource_name} for {user_google_email}") return response - except HttpError as error: - if error.resp.status == 404: - message = f"Contact not found: {contact_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) - - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("delete_contact", service_type="people") -async def delete_contact( - service: Resource, - user_google_email: str, - contact_id: str, -) -> str: - """ - Delete a contact. - - Args: - user_google_email (str): The user's Google email address. Required. - contact_id (str): The contact ID to delete. - - Returns: - str: Confirmation message. - """ - # Normalize resource name - if not contact_id.startswith("people/"): - resource_name = f"people/{contact_id}" - else: - resource_name = contact_id - - logger.info( - f"[delete_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}" + # action == "delete" + await asyncio.to_thread( + service.people().deleteContact(resourceName=resource_name).execute ) - try: - await asyncio.to_thread( - service.people().deleteContact(resourceName=resource_name).execute - ) + response = f"Contact {contact_id} has been deleted for {user_google_email}." + logger.info(f"Deleted contact {resource_name} for {user_google_email}") + return response - response = f"Contact {contact_id} has been deleted for {user_google_email}." - logger.info(f"Deleted contact {resource_name} for {user_google_email}") - return response - - except HttpError as error: - if error.resp.status == 404: - message = f"Contact not found: {contact_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) +# ============================================================================= +# Extended Tier Tools +# ============================================================================= @server.tool() @@ -682,51 +569,45 @@ async def list_contact_groups( """ logger.info(f"[list_contact_groups] Invoked. Email: '{user_google_email}'") - try: - params: Dict[str, Any] = { - "pageSize": min(page_size, 1000), - "groupFields": CONTACT_GROUP_FIELDS, - } + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 1000) - if page_token: - params["pageToken"] = page_token + params: Dict[str, Any] = { + "pageSize": page_size, + "groupFields": CONTACT_GROUP_FIELDS, + } - result = await asyncio.to_thread(service.contactGroups().list(**params).execute) + if page_token: + params["pageToken"] = page_token - groups = result.get("contactGroups", []) - next_page_token = result.get("nextPageToken") + result = await asyncio.to_thread(service.contactGroups().list(**params).execute) - if not groups: - return f"No contact groups found for {user_google_email}." + groups = result.get("contactGroups", []) + next_page_token = result.get("nextPageToken") - response = f"Contact Groups for {user_google_email}:\n\n" + if not groups: + return f"No contact groups found for {user_google_email}." - for group in groups: - resource_name = group.get("resourceName", "") - group_id = resource_name.replace("contactGroups/", "") - name = group.get("name", "Unnamed") - group_type = group.get("groupType", "USER_CONTACT_GROUP") - member_count = group.get("memberCount", 0) + response = f"Contact Groups for {user_google_email}:\n\n" - response += f"- {name}\n" - response += f" ID: {group_id}\n" - response += f" Type: {group_type}\n" - response += f" Members: {member_count}\n\n" + for group in groups: + resource_name = group.get("resourceName", "") + group_id = resource_name.replace("contactGroups/", "") + name = group.get("name", "Unnamed") + group_type = group.get("groupType", "USER_CONTACT_GROUP") + member_count = group.get("memberCount", 0) - if next_page_token: - response += f"Next page token: {next_page_token}" + response += f"- {name}\n" + response += f" ID: {group_id}\n" + response += f" Type: {group_type}\n" + response += f" Members: {member_count}\n\n" - logger.info(f"Found {len(groups)} contact groups for {user_google_email}") - return response + if next_page_token: + response += f"Next page token: {next_page_token}" - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Found {len(groups)} contact groups for {user_google_email}") + return response @server.tool() @@ -759,49 +640,39 @@ async def get_contact_group( f"[get_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}" ) - try: - result = await asyncio.to_thread( - service.contactGroups() - .get( - resourceName=resource_name, - maxMembers=min(max_members, 1000), - groupFields=CONTACT_GROUP_FIELDS, - ) - .execute + if max_members < 1: + raise UserInputError("max_members must be >= 1") + max_members = min(max_members, 1000) + + result = await asyncio.to_thread( + service.contactGroups() + .get( + resourceName=resource_name, + maxMembers=max_members, + groupFields=CONTACT_GROUP_FIELDS, ) + .execute + ) - name = result.get("name", "Unnamed") - group_type = result.get("groupType", "USER_CONTACT_GROUP") - member_count = result.get("memberCount", 0) - member_resource_names = result.get("memberResourceNames", []) + name = result.get("name", "Unnamed") + group_type = result.get("groupType", "USER_CONTACT_GROUP") + member_count = result.get("memberCount", 0) + member_resource_names = result.get("memberResourceNames", []) - response = f"Contact Group Details for {user_google_email}:\n\n" - response += f"Name: {name}\n" - response += f"ID: {group_id}\n" - response += f"Type: {group_type}\n" - response += f"Total Members: {member_count}\n" + response = f"Contact Group Details for {user_google_email}:\n\n" + response += f"Name: {name}\n" + response += f"ID: {group_id}\n" + response += f"Type: {group_type}\n" + response += f"Total Members: {member_count}\n" - if member_resource_names: - response += f"\nMembers ({len(member_resource_names)} shown):\n" - for member in member_resource_names: - contact_id = member.replace("people/", "") - response += f" - {contact_id}\n" + if member_resource_names: + response += f"\nMembers ({len(member_resource_names)} shown):\n" + for member in member_resource_names: + contact_id = member.replace("people/", "") + response += f" - {contact_id}\n" - logger.info(f"Retrieved contact group {resource_name} for {user_google_email}") - return response - - except HttpError as error: - if error.resp.status == 404: - message = f"Contact group not found: {group_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Retrieved contact group {resource_name} for {user_google_email}") + return response # ============================================================================= @@ -811,40 +682,49 @@ async def get_contact_group( @server.tool() @require_google_service("people", "contacts") -@handle_http_errors("batch_create_contacts", service_type="people") -async def batch_create_contacts( +@handle_http_errors("manage_contacts_batch", service_type="people") +async def manage_contacts_batch( service: Resource, user_google_email: str, - contacts: List[Dict[str, str]], + action: str, + contacts: Optional[List[Dict[str, str]]] = None, + updates: Optional[List[Dict[str, str]]] = None, + contact_ids: Optional[List[str]] = None, ) -> str: """ - Create multiple contacts in a batch operation. + Batch create, update, or delete contacts. Consolidated tool replacing + batch_create_contacts, batch_update_contacts, and batch_delete_contacts. Args: user_google_email (str): The user's Google email address. Required. - contacts (List[Dict[str, str]]): List of contact dictionaries with fields: - - given_name: First name - - family_name: Last name - - email: Email address - - phone: Phone number - - organization: Company name - - job_title: Job title + action (str): The action to perform: "create", "update", or "delete". + contacts (Optional[List[Dict[str, str]]]): List of contact dicts for "create" action. + Each dict may contain: given_name, family_name, email, phone, organization, job_title. + updates (Optional[List[Dict[str, str]]]): List of update dicts for "update" action. + Each dict must contain contact_id and may contain: given_name, family_name, + email, phone, organization, job_title. + contact_ids (Optional[List[str]]): List of contact IDs for "delete" action. Returns: - str: Confirmation with created contacts. + str: Result of the batch action performed. """ + action = action.lower().strip() + if action not in ("create", "update", "delete"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) + logger.info( - f"[batch_create_contacts] Invoked. Email: '{user_google_email}', Count: {len(contacts)}" + f"[manage_contacts_batch] Invoked. Action: '{action}', Email: '{user_google_email}'" ) - try: + if action == "create": if not contacts: - raise Exception("At least one contact must be provided.") + raise UserInputError("contacts parameter is required for 'create' action.") if len(contacts) > 200: - raise Exception("Maximum 200 contacts can be created in a batch.") + raise UserInputError("Maximum 200 contacts can be created in a batch.") - # Build batch request body contact_bodies = [] for contact in contacts: body = _build_person_body( @@ -859,7 +739,7 @@ async def batch_create_contacts( contact_bodies.append({"contactPerson": body}) if not contact_bodies: - raise Exception("No valid contact data provided.") + raise UserInputError("No valid contact data provided.") batch_body = { "contacts": contact_bodies, @@ -884,63 +764,23 @@ async def batch_create_contacts( ) return response - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) - - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("batch_update_contacts", service_type="people") -async def batch_update_contacts( - service: Resource, - user_google_email: str, - updates: List[Dict[str, str]], -) -> str: - """ - Update multiple contacts in a batch operation. - - Args: - user_google_email (str): The user's Google email address. Required. - updates (List[Dict[str, str]]): List of update dictionaries with fields: - - contact_id: The contact ID to update (required) - - given_name: New first name - - family_name: New last name - - email: New email address - - phone: New phone number - - organization: New company name - - job_title: New job title - - Returns: - str: Confirmation with updated contacts. - """ - logger.info( - f"[batch_update_contacts] Invoked. Email: '{user_google_email}', Count: {len(updates)}" - ) - - try: + if action == "update": if not updates: - raise Exception("At least one update must be provided.") + raise UserInputError("updates parameter is required for 'update' action.") if len(updates) > 200: - raise Exception("Maximum 200 contacts can be updated in a batch.") + raise UserInputError("Maximum 200 contacts can be updated in a batch.") - # First, fetch all contacts to get their etags + # Fetch all contacts to get their etags resource_names = [] for update in updates: - contact_id = update.get("contact_id") - if not contact_id: - raise Exception("Each update must include a contact_id.") - if not contact_id.startswith("people/"): - contact_id = f"people/{contact_id}" - resource_names.append(contact_id) + cid = update.get("contact_id") + if not cid: + raise UserInputError("Each update must include a contact_id.") + if not cid.startswith("people/"): + cid = f"people/{cid}" + resource_names.append(cid) - # Batch get contacts for etags batch_get_result = await asyncio.to_thread( service.people() .getBatchGet( @@ -951,25 +791,24 @@ async def batch_update_contacts( ) etags = {} - for response in batch_get_result.get("responses", []): - person = response.get("person", {}) - resource_name = person.get("resourceName") + for resp in batch_get_result.get("responses", []): + person = resp.get("person", {}) + rname = person.get("resourceName") etag = person.get("etag") - if resource_name and etag: - etags[resource_name] = etag + if rname and etag: + etags[rname] = etag - # Build batch update body update_bodies = [] update_fields_set: set = set() for update in updates: - contact_id = update.get("contact_id", "") - if not contact_id.startswith("people/"): - contact_id = f"people/{contact_id}" + cid = update.get("contact_id", "") + if not cid.startswith("people/"): + cid = f"people/{cid}" - etag = etags.get(contact_id) + etag = etags.get(cid) if not etag: - logger.warning(f"No etag found for {contact_id}, skipping") + logger.warning(f"No etag found for {cid}, skipping") continue body = _build_person_body( @@ -982,11 +821,10 @@ async def batch_update_contacts( ) if body: - body["resourceName"] = contact_id + body["resourceName"] = cid body["etag"] = etag update_bodies.append({"person": body}) - # Track which fields are being updated if "names" in body: update_fields_set.add("names") if "emailAddresses" in body: @@ -997,7 +835,7 @@ async def batch_update_contacts( update_fields_set.add("organizations") if not update_bodies: - raise Exception("No valid update data provided.") + raise UserInputError("No valid update data provided.") batch_body = { "contacts": update_bodies, @@ -1014,7 +852,7 @@ async def batch_update_contacts( response = f"Batch Update Results for {user_google_email}:\n\n" response += f"Updated {len(update_results)} contacts:\n\n" - for resource_name, update_result in update_results.items(): + for rname, update_result in update_results.items(): person = update_result.get("person", {}) response += _format_contact(person) + "\n\n" @@ -1023,99 +861,77 @@ async def batch_update_contacts( ) return response - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + # action == "delete" + if not contact_ids: + raise UserInputError("contact_ids parameter is required for 'delete' action.") + + if len(contact_ids) > 500: + raise UserInputError("Maximum 500 contacts can be deleted in a batch.") + + resource_names = [] + for cid in contact_ids: + if not cid.startswith("people/"): + resource_names.append(f"people/{cid}") + else: + resource_names.append(cid) + + batch_body = {"resourceNames": resource_names} + + await asyncio.to_thread( + service.people().batchDeleteContacts(body=batch_body).execute + ) + + response = f"Batch deleted {len(contact_ids)} contacts for {user_google_email}." + logger.info(f"Batch deleted {len(contact_ids)} contacts for {user_google_email}") + return response @server.tool() @require_google_service("people", "contacts") -@handle_http_errors("batch_delete_contacts", service_type="people") -async def batch_delete_contacts( +@handle_http_errors("manage_contact_group", service_type="people") +async def manage_contact_group( service: Resource, user_google_email: str, - contact_ids: List[str], + action: str, + group_id: Optional[str] = None, + name: Optional[str] = None, + delete_contacts: bool = False, + add_contact_ids: Optional[List[str]] = None, + remove_contact_ids: Optional[List[str]] = None, ) -> str: """ - Delete multiple contacts in a batch operation. + Create, update, delete a contact group, or modify its members. Consolidated tool + replacing create_contact_group, update_contact_group, delete_contact_group, and + modify_contact_group_members. Args: user_google_email (str): The user's Google email address. Required. - contact_ids (List[str]): List of contact IDs to delete. + action (str): The action to perform: "create", "update", "delete", or "modify_members". + group_id (Optional[str]): The contact group ID. Required for "update", "delete", + and "modify_members" actions. + name (Optional[str]): The group name. Required for "create" and "update" actions. + delete_contacts (bool): If True and action is "delete", also delete contacts in + the group (default: False). + add_contact_ids (Optional[List[str]]): Contact IDs to add (for "modify_members"). + remove_contact_ids (Optional[List[str]]): Contact IDs to remove (for "modify_members"). Returns: - str: Confirmation message. + str: Result of the action performed. """ - logger.info( - f"[batch_delete_contacts] Invoked. Email: '{user_google_email}', Count: {len(contact_ids)}" - ) - - try: - if not contact_ids: - raise Exception("At least one contact ID must be provided.") - - if len(contact_ids) > 500: - raise Exception("Maximum 500 contacts can be deleted in a batch.") - - # Normalize resource names - resource_names = [] - for contact_id in contact_ids: - if not contact_id.startswith("people/"): - resource_names.append(f"people/{contact_id}") - else: - resource_names.append(contact_id) - - batch_body = {"resourceNames": resource_names} - - await asyncio.to_thread( - service.people().batchDeleteContacts(body=batch_body).execute + action = action.lower().strip() + if action not in ("create", "update", "delete", "modify_members"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', 'delete', or 'modify_members'." ) - response = f"Batch deleted {len(contact_ids)} contacts for {user_google_email}." - - logger.info( - f"Batch deleted {len(contact_ids)} contacts for {user_google_email}" - ) - return response - - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) - - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("create_contact_group", service_type="people") -async def create_contact_group( - service: Resource, - user_google_email: str, - name: str, -) -> str: - """ - Create a new contact group (label). - - Args: - user_google_email (str): The user's Google email address. Required. - name (str): The name of the new contact group. - - Returns: - str: Confirmation with the new group details. - """ logger.info( - f"[create_contact_group] Invoked. Email: '{user_google_email}', Name: '{name}'" + f"[manage_contact_group] Invoked. Action: '{action}', Email: '{user_google_email}'" ) - try: + if action == "create": + if not name: + raise UserInputError("name is required for 'create' action.") + body = {"contactGroup": {"name": name}} result = await asyncio.to_thread( @@ -1123,58 +939,31 @@ async def create_contact_group( ) resource_name = result.get("resourceName", "") - group_id = resource_name.replace("contactGroups/", "") + created_group_id = resource_name.replace("contactGroups/", "") created_name = result.get("name", name) response = f"Contact Group Created for {user_google_email}:\n\n" response += f"Name: {created_name}\n" - response += f"ID: {group_id}\n" + response += f"ID: {created_group_id}\n" response += f"Type: {result.get('groupType', 'USER_CONTACT_GROUP')}\n" logger.info(f"Created contact group '{name}' for {user_google_email}") return response - except HttpError as error: - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + # All other actions require group_id + if not group_id: + raise UserInputError(f"group_id is required for '{action}' action.") - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("update_contact_group", service_type="people") -async def update_contact_group( - service: Resource, - user_google_email: str, - group_id: str, - name: str, -) -> str: - """ - Update a contact group's name. - - Args: - user_google_email (str): The user's Google email address. Required. - group_id (str): The contact group ID to update. - name (str): The new name for the contact group. - - Returns: - str: Confirmation with updated group details. - """ # Normalize resource name if not group_id.startswith("contactGroups/"): resource_name = f"contactGroups/{group_id}" else: resource_name = group_id - logger.info( - f"[update_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}" - ) + if action == "update": + if not name: + raise UserInputError("name is required for 'update' action.") - try: body = {"contactGroup": {"name": name}} result = await asyncio.to_thread( @@ -1192,51 +981,7 @@ async def update_contact_group( logger.info(f"Updated contact group {resource_name} for {user_google_email}") return response - except HttpError as error: - if error.resp.status == 404: - message = f"Contact group not found: {group_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) - - -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("delete_contact_group", service_type="people") -async def delete_contact_group( - service: Resource, - user_google_email: str, - group_id: str, - delete_contacts: bool = False, -) -> str: - """ - Delete a contact group. - - Args: - user_google_email (str): The user's Google email address. Required. - group_id (str): The contact group ID to delete. - delete_contacts (bool): If True, also delete contacts in the group (default: False). - - Returns: - str: Confirmation message. - """ - # Normalize resource name - if not group_id.startswith("contactGroups/"): - resource_name = f"contactGroups/{group_id}" - else: - resource_name = group_id - - logger.info( - f"[delete_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}" - ) - - try: + if action == "delete": await asyncio.to_thread( service.contactGroups() .delete(resourceName=resource_name, deleteContacts=delete_contacts) @@ -1252,117 +997,56 @@ async def delete_contact_group( logger.info(f"Deleted contact group {resource_name} for {user_google_email}") return response - except HttpError as error: - if error.resp.status == 404: - message = f"Contact group not found: {group_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + # action == "modify_members" + if not add_contact_ids and not remove_contact_ids: + raise UserInputError( + "At least one of add_contact_ids or remove_contact_ids must be provided." + ) + modify_body: Dict[str, Any] = {} -@server.tool() -@require_google_service("people", "contacts") -@handle_http_errors("modify_contact_group_members", service_type="people") -async def modify_contact_group_members( - service: Resource, - user_google_email: str, - group_id: str, - add_contact_ids: Optional[List[str]] = None, - remove_contact_ids: Optional[List[str]] = None, -) -> str: - """ - Add or remove contacts from a contact group. + if add_contact_ids: + add_names = [] + for contact_id in add_contact_ids: + if not contact_id.startswith("people/"): + add_names.append(f"people/{contact_id}") + else: + add_names.append(contact_id) + modify_body["resourceNamesToAdd"] = add_names - Args: - user_google_email (str): The user's Google email address. Required. - group_id (str): The contact group ID. - add_contact_ids (Optional[List[str]]): Contact IDs to add to the group. - remove_contact_ids (Optional[List[str]]): Contact IDs to remove from the group. + if remove_contact_ids: + remove_names = [] + for contact_id in remove_contact_ids: + if not contact_id.startswith("people/"): + remove_names.append(f"people/{contact_id}") + else: + remove_names.append(contact_id) + modify_body["resourceNamesToRemove"] = remove_names - Returns: - str: Confirmation with results. - """ - # Normalize resource name - if not group_id.startswith("contactGroups/"): - resource_name = f"contactGroups/{group_id}" - else: - resource_name = group_id - - logger.info( - f"[modify_contact_group_members] Invoked. Email: '{user_google_email}', Group: {resource_name}" + result = await asyncio.to_thread( + service.contactGroups() + .members() + .modify(resourceName=resource_name, body=modify_body) + .execute ) - try: - if not add_contact_ids and not remove_contact_ids: - raise Exception( - "At least one of add_contact_ids or remove_contact_ids must be provided." - ) + not_found = result.get("notFoundResourceNames", []) + cannot_remove = result.get("canNotRemoveLastContactGroupResourceNames", []) - body: Dict[str, Any] = {} + response = f"Contact Group Members Modified for {user_google_email}:\n\n" + response += f"Group: {group_id}\n" - if add_contact_ids: - # Normalize resource names - add_names = [] - for contact_id in add_contact_ids: - if not contact_id.startswith("people/"): - add_names.append(f"people/{contact_id}") - else: - add_names.append(contact_id) - body["resourceNamesToAdd"] = add_names + if add_contact_ids: + response += f"Added: {len(add_contact_ids)} contacts\n" + if remove_contact_ids: + response += f"Removed: {len(remove_contact_ids)} contacts\n" - if remove_contact_ids: - # Normalize resource names - remove_names = [] - for contact_id in remove_contact_ids: - if not contact_id.startswith("people/"): - remove_names.append(f"people/{contact_id}") - else: - remove_names.append(contact_id) - body["resourceNamesToRemove"] = remove_names + if not_found: + response += f"\nNot found: {', '.join(not_found)}\n" + if cannot_remove: + response += f"\nCannot remove (last group): {', '.join(cannot_remove)}\n" - result = await asyncio.to_thread( - service.contactGroups() - .members() - .modify(resourceName=resource_name, body=body) - .execute - ) - - not_found = result.get("notFoundResourceNames", []) - cannot_remove = result.get("canNotRemoveLastContactGroupResourceNames", []) - - response = f"Contact Group Members Modified for {user_google_email}:\n\n" - response += f"Group: {group_id}\n" - - if add_contact_ids: - response += f"Added: {len(add_contact_ids)} contacts\n" - if remove_contact_ids: - response += f"Removed: {len(remove_contact_ids)} contacts\n" - - if not_found: - response += f"\nNot found: {', '.join(not_found)}\n" - if cannot_remove: - response += f"\nCannot remove (last group): {', '.join(cannot_remove)}\n" - - logger.info( - f"Modified contact group members for {resource_name} for {user_google_email}" - ) - return response - - except HttpError as error: - if error.resp.status == 404: - message = f"Contact group not found: {group_id}" - logger.warning(message) - raise Exception(message) - message = f"API error: {error}. You might need to re-authenticate. LLM: Try 'start_google_auth' with the user's email ({user_google_email}) and service_name='Google Contacts'." - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info( + f"Modified contact group members for {resource_name} for {user_google_email}" + ) + return response diff --git a/gdocs/docs_markdown.py b/gdocs/docs_markdown.py index 41a69d1..d9c183d 100644 --- a/gdocs/docs_markdown.py +++ b/gdocs/docs_markdown.py @@ -5,6 +5,7 @@ Converts Google Docs API JSON responses to clean Markdown, preserving: - Headings (H1-H6, Title, Subtitle) - Bold, italic, strikethrough, code, links - Ordered and unordered lists with nesting +- Checklists with checked/unchecked state - Tables with header row separators """ @@ -60,9 +61,20 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str: if bullet: list_id = bullet["listId"] nesting = bullet.get("nestingLevel", 0) - is_ordered = _is_ordered_list(lists_meta, list_id, nesting) - if is_ordered: + if _is_checklist(lists_meta, list_id, nesting): + checked = _is_checked(para) + checkbox = "[x]" if checked else "[ ]" + indent = " " * nesting + # Re-render text without strikethrough for checked items + # to avoid redundant ~~text~~ alongside [x] + cb_text = ( + _convert_paragraph_text(para, skip_strikethrough=True) + if checked + else text + ) + lines.append(f"{indent}- {checkbox} {cb_text}") + elif _is_ordered_list(lists_meta, list_id, nesting): key = (list_id, nesting) ordered_counters[key] = ordered_counters.get(key, 0) + 1 counter = ordered_counters[key] @@ -102,16 +114,20 @@ def convert_doc_to_markdown(doc: dict[str, Any]) -> str: return result -def _convert_paragraph_text(para: dict[str, Any]) -> str: +def _convert_paragraph_text( + para: dict[str, Any], skip_strikethrough: bool = False +) -> str: """Convert paragraph elements to inline markdown text.""" parts: list[str] = [] for elem in para.get("elements", []): if "textRun" in elem: - parts.append(_convert_text_run(elem["textRun"])) + parts.append(_convert_text_run(elem["textRun"], skip_strikethrough)) return "".join(parts).strip() -def _convert_text_run(text_run: dict[str, Any]) -> str: +def _convert_text_run( + text_run: dict[str, Any], skip_strikethrough: bool = False +) -> str: """Convert a single text run to markdown.""" content = text_run.get("content", "") style = text_run.get("textStyle", {}) @@ -120,10 +136,12 @@ def _convert_text_run(text_run: dict[str, Any]) -> str: if not text: return "" - return _apply_text_style(text, style) + return _apply_text_style(text, style, skip_strikethrough) -def _apply_text_style(text: str, style: dict[str, Any]) -> str: +def _apply_text_style( + text: str, style: dict[str, Any], skip_strikethrough: bool = False +) -> str: """Apply markdown formatting based on text style.""" link = style.get("link", {}) url = link.get("url") @@ -143,7 +161,7 @@ def _apply_text_style(text: str, style: dict[str, Any]) -> str: elif italic: text = f"*{text}*" - if strikethrough: + if strikethrough and not skip_strikethrough: text = f"~~{text}~~" if url: @@ -163,6 +181,37 @@ def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) -> return False +def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool: + """Check if a list at a given nesting level is a checklist. + + Google Docs checklists are distinguished from regular bullet lists by having + GLYPH_TYPE_UNSPECIFIED with no glyphSymbol — the Docs UI renders interactive + checkboxes rather than a static glyph character. + """ + list_info = lists_meta.get(list_id, {}) + nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", []) + if nesting < len(nesting_levels): + level = nesting_levels[nesting] + glyph_type = level.get("glyphType", "") + has_glyph_symbol = "glyphSymbol" in level + return glyph_type in ("", "GLYPH_TYPE_UNSPECIFIED") and not has_glyph_symbol + return False + + +def _is_checked(para: dict[str, Any]) -> bool: + """Check if a checklist item is checked. + + Google Docs marks checked checklist items by applying strikethrough + formatting to the paragraph text. + """ + for elem in para.get("elements", []): + if "textRun" in elem: + content = elem["textRun"].get("content", "").strip() + if content: + return elem["textRun"].get("textStyle", {}).get("strikethrough", False) + return False + + def _convert_table(table: dict[str, Any]) -> str: """Convert a table element to markdown.""" rows = table.get("tableRows", []) diff --git a/gdocs/docs_tools.py b/gdocs/docs_tools.py index a044e06..218ccbd 100644 --- a/gdocs/docs_tools.py +++ b/gdocs/docs_tools.py @@ -1678,7 +1678,5 @@ async def get_doc_as_markdown( _comment_tools = create_comment_tools("document", "document_id") # Extract and register the functions -read_doc_comments = _comment_tools["read_comments"] -create_doc_comment = _comment_tools["create_comment"] -reply_to_comment = _comment_tools["reply_to_comment"] -resolve_comment = _comment_tools["resolve_comment"] +list_document_comments = _comment_tools["list_comments"] +manage_document_comment = _comment_tools["manage_comment"] diff --git a/gdrive/drive_helpers.py b/gdrive/drive_helpers.py index db6ec40..55e342a 100644 --- a/gdrive/drive_helpers.py +++ b/gdrive/drive_helpers.py @@ -305,6 +305,7 @@ def resolve_file_type_mime(file_type: str) -> str: ) return FILE_TYPE_MIME_MAP[lower] + BASE_SHORTCUT_FIELDS = ( "id, mimeType, parents, shortcutDetails(targetId, targetMimeType)" ) diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py index 8f34576..f2120e8 100644 --- a/gdrive/drive_tools.py +++ b/gdrive/drive_tools.py @@ -1774,365 +1774,368 @@ async def get_drive_shareable_link( @server.tool() -@handle_http_errors("share_drive_file", is_read_only=False, service_type="drive") +@handle_http_errors("manage_drive_access", is_read_only=False, service_type="drive") @require_google_service("drive", "drive_file") -async def share_drive_file( +async def manage_drive_access( service, user_google_email: str, file_id: str, + action: str, share_with: Optional[str] = None, - role: str = "reader", + role: Optional[str] = None, share_type: str = "user", + permission_id: Optional[str] = None, + recipients: Optional[List[Dict[str, Any]]] = None, send_notification: bool = True, email_message: Optional[str] = None, expiration_time: Optional[str] = None, allow_file_discovery: Optional[bool] = None, + new_owner_email: Optional[str] = None, + move_to_new_owners_root: bool = False, ) -> str: """ - Shares a Google Drive file or folder with a user, group, domain, or anyone with the link. + Consolidated tool for managing Google Drive file and folder access permissions. - When sharing a folder, all files inside inherit the permission. + Supports granting, batch-granting, updating, revoking permissions, and + transferring file ownership -- all through a single entry point. Args: user_google_email (str): The user's Google email address. Required. - file_id (str): The ID of the file or folder to share. Required. - share_with (Optional[str]): Email address (for user/group), domain name (for domain), or omit for 'anyone'. - role (str): Permission role - 'reader', 'commenter', or 'writer'. Defaults to 'reader'. - share_type (str): Type of sharing - 'user', 'group', 'domain', or 'anyone'. Defaults to 'user'. - send_notification (bool): Whether to send a notification email. Defaults to True. - email_message (Optional[str]): Custom message for the notification email. - expiration_time (Optional[str]): Expiration time in RFC 3339 format (e.g., "2025-01-15T00:00:00Z"). Permission auto-revokes after this time. - allow_file_discovery (Optional[bool]): For 'domain' or 'anyone' shares - whether the file can be found via search. Defaults to None (API default). - - Returns: - str: Confirmation with permission details and shareable link. - """ - logger.info( - f"[share_drive_file] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Share with: '{share_with}', Role: '{role}', Type: '{share_type}'" - ) - - validate_share_role(role) - validate_share_type(share_type) - - if share_type in ("user", "group") and not share_with: - raise ValueError(f"share_with is required for share_type '{share_type}'") - if share_type == "domain" and not share_with: - raise ValueError("share_with (domain name) is required for share_type 'domain'") - - resolved_file_id, file_metadata = await resolve_drive_item( - service, file_id, extra_fields="name, webViewLink" - ) - file_id = resolved_file_id - - permission_body = { - "type": share_type, - "role": role, - } - - if share_type in ("user", "group"): - permission_body["emailAddress"] = share_with - elif share_type == "domain": - permission_body["domain"] = share_with - - if expiration_time: - validate_expiration_time(expiration_time) - permission_body["expirationTime"] = expiration_time - - if share_type in ("domain", "anyone") and allow_file_discovery is not None: - permission_body["allowFileDiscovery"] = allow_file_discovery - - create_params = { - "fileId": file_id, - "body": permission_body, - "supportsAllDrives": True, - "fields": "id, type, role, emailAddress, domain, expirationTime", - } - - if share_type in ("user", "group"): - create_params["sendNotificationEmail"] = send_notification - if email_message: - create_params["emailMessage"] = email_message - - created_permission = await asyncio.to_thread( - service.permissions().create(**create_params).execute - ) - - output_parts = [ - f"Successfully shared '{file_metadata.get('name', 'Unknown')}'", - "", - "Permission created:", - f" - {format_permission_info(created_permission)}", - "", - f"View link: {file_metadata.get('webViewLink', 'N/A')}", - ] - - return "\n".join(output_parts) - - -@server.tool() -@handle_http_errors("batch_share_drive_file", is_read_only=False, service_type="drive") -@require_google_service("drive", "drive_file") -async def batch_share_drive_file( - service, - user_google_email: str, - file_id: str, - recipients: List[Dict[str, Any]], - send_notification: bool = True, - email_message: Optional[str] = None, -) -> str: - """ - Shares a Google Drive file or folder with multiple users or groups in a single operation. - - Each recipient can have a different role and optional expiration time. - - Note: Each recipient is processed sequentially. For very large recipient lists, - consider splitting into multiple calls. - - Args: - user_google_email (str): The user's Google email address. Required. - file_id (str): The ID of the file or folder to share. Required. - recipients (List[Dict]): List of recipient objects. Each should have: - - email (str): Recipient email address. Required for 'user' or 'group' share_type. - - role (str): Permission role - 'reader', 'commenter', or 'writer'. Defaults to 'reader'. - - share_type (str, optional): 'user', 'group', or 'domain'. Defaults to 'user'. - - expiration_time (str, optional): Expiration in RFC 3339 format (e.g., "2025-01-15T00:00:00Z"). - For domain shares, use 'domain' field instead of 'email': - - domain (str): Domain name. Required when share_type is 'domain'. + file_id (str): The ID of the file or folder. Required. + action (str): The access management action to perform. Required. One of: + - "grant": Share with a single user, group, domain, or anyone. + - "grant_batch": Share with multiple recipients in one call. + - "update": Modify an existing permission (role or expiration). + - "revoke": Remove an existing permission. + - "transfer_owner": Transfer file ownership to another user. + share_with (Optional[str]): Email address (user/group), domain name (domain), + or omit for 'anyone'. Used by "grant". + role (Optional[str]): Permission role -- 'reader', 'commenter', or 'writer'. + Used by "grant" (defaults to 'reader') and "update". + share_type (str): Type of sharing -- 'user', 'group', 'domain', or 'anyone'. + Used by "grant". Defaults to 'user'. + permission_id (Optional[str]): The permission ID to modify or remove. + Required for "update" and "revoke" actions. + recipients (Optional[List[Dict[str, Any]]]): List of recipient objects for + "grant_batch". Each should have: email (str), role (str, optional), + share_type (str, optional), expiration_time (str, optional). For domain + shares use 'domain' field instead of 'email'. send_notification (bool): Whether to send notification emails. Defaults to True. - email_message (Optional[str]): Custom message for notification emails. + Used by "grant" and "grant_batch". + email_message (Optional[str]): Custom notification email message. + Used by "grant" and "grant_batch". + expiration_time (Optional[str]): Expiration in RFC 3339 format + (e.g., "2025-01-15T00:00:00Z"). Used by "grant" and "update". + allow_file_discovery (Optional[bool]): For 'domain'/'anyone' shares, whether + the file appears in search. Used by "grant". + new_owner_email (Optional[str]): Email of the new owner. + Required for "transfer_owner". + move_to_new_owners_root (bool): Move file to the new owner's My Drive root. + Defaults to False. Used by "transfer_owner". Returns: - str: Summary of created permissions with success/failure for each recipient. + str: Confirmation with details of the permission change applied. """ + valid_actions = ("grant", "grant_batch", "update", "revoke", "transfer_owner") + if action not in valid_actions: + raise ValueError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + logger.info( - f"[batch_share_drive_file] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Recipients: {len(recipients)}" + f"[manage_drive_access] Invoked. Email: '{user_google_email}', " + f"File ID: '{file_id}', Action: '{action}'" ) - resolved_file_id, file_metadata = await resolve_drive_item( - service, file_id, extra_fields="name, webViewLink" - ) - file_id = resolved_file_id + # --- grant: share with a single recipient --- + if action == "grant": + effective_role = role or "reader" + validate_share_role(effective_role) + validate_share_type(share_type) - if not recipients: - raise ValueError("recipients list cannot be empty") + if share_type in ("user", "group") and not share_with: + raise ValueError(f"share_with is required for share_type '{share_type}'") + if share_type == "domain" and not share_with: + raise ValueError( + "share_with (domain name) is required for share_type 'domain'" + ) - results = [] - success_count = 0 - failure_count = 0 + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink" + ) + file_id = resolved_file_id - for recipient in recipients: - share_type = recipient.get("share_type", "user") - - if share_type == "domain": - domain = recipient.get("domain") - if not domain: - results.append(" - Skipped: missing domain for domain share") - failure_count += 1 - continue - identifier = domain - else: - email = recipient.get("email") - if not email: - results.append(" - Skipped: missing email address") - failure_count += 1 - continue - identifier = email - - role = recipient.get("role", "reader") - try: - validate_share_role(role) - except ValueError as e: - results.append(f" - {identifier}: Failed - {e}") - failure_count += 1 - continue - - try: - validate_share_type(share_type) - except ValueError as e: - results.append(f" - {identifier}: Failed - {e}") - failure_count += 1 - continue - - permission_body = { + permission_body: Dict[str, Any] = { "type": share_type, - "role": role, + "role": effective_role, } + if share_type in ("user", "group"): + permission_body["emailAddress"] = share_with + elif share_type == "domain": + permission_body["domain"] = share_with - if share_type == "domain": - permission_body["domain"] = identifier - else: - permission_body["emailAddress"] = identifier + if expiration_time: + validate_expiration_time(expiration_time) + permission_body["expirationTime"] = expiration_time - if recipient.get("expiration_time"): - try: - validate_expiration_time(recipient["expiration_time"]) - permission_body["expirationTime"] = recipient["expiration_time"] - except ValueError as e: - results.append(f" - {identifier}: Failed - {e}") - failure_count += 1 - continue + if share_type in ("domain", "anyone") and allow_file_discovery is not None: + permission_body["allowFileDiscovery"] = allow_file_discovery - create_params = { + create_params: Dict[str, Any] = { "fileId": file_id, "body": permission_body, "supportsAllDrives": True, "fields": "id, type, role, emailAddress, domain, expirationTime", } - if share_type in ("user", "group"): create_params["sendNotificationEmail"] = send_notification if email_message: create_params["emailMessage"] = email_message - try: - created_permission = await asyncio.to_thread( - service.permissions().create(**create_params).execute - ) - results.append(f" - {format_permission_info(created_permission)}") - success_count += 1 - except HttpError as e: - results.append(f" - {identifier}: Failed - {str(e)}") - failure_count += 1 + created_permission = await asyncio.to_thread( + service.permissions().create(**create_params).execute + ) - output_parts = [ - f"Batch share results for '{file_metadata.get('name', 'Unknown')}'", - "", - f"Summary: {success_count} succeeded, {failure_count} failed", - "", - "Results:", - ] - output_parts.extend(results) - output_parts.extend( - [ + return "\n".join( + [ + f"Successfully shared '{file_metadata.get('name', 'Unknown')}'", + "", + "Permission created:", + f" - {format_permission_info(created_permission)}", + "", + f"View link: {file_metadata.get('webViewLink', 'N/A')}", + ] + ) + + # --- grant_batch: share with multiple recipients --- + if action == "grant_batch": + if not recipients: + raise ValueError("recipients list is required for 'grant_batch' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink" + ) + file_id = resolved_file_id + + results: List[str] = [] + success_count = 0 + failure_count = 0 + + for recipient in recipients: + r_share_type = recipient.get("share_type", "user") + + if r_share_type == "domain": + domain = recipient.get("domain") + if not domain: + results.append(" - Skipped: missing domain for domain share") + failure_count += 1 + continue + identifier = domain + else: + r_email = recipient.get("email") + if not r_email: + results.append(" - Skipped: missing email address") + failure_count += 1 + continue + identifier = r_email + + r_role = recipient.get("role", "reader") + try: + validate_share_role(r_role) + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + try: + validate_share_type(r_share_type) + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + r_perm_body: Dict[str, Any] = { + "type": r_share_type, + "role": r_role, + } + if r_share_type == "domain": + r_perm_body["domain"] = identifier + else: + r_perm_body["emailAddress"] = identifier + + if recipient.get("expiration_time"): + try: + validate_expiration_time(recipient["expiration_time"]) + r_perm_body["expirationTime"] = recipient["expiration_time"] + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + r_create_params: Dict[str, Any] = { + "fileId": file_id, + "body": r_perm_body, + "supportsAllDrives": True, + "fields": "id, type, role, emailAddress, domain, expirationTime", + } + if r_share_type in ("user", "group"): + r_create_params["sendNotificationEmail"] = send_notification + if email_message: + r_create_params["emailMessage"] = email_message + + try: + created_perm = await asyncio.to_thread( + service.permissions().create(**r_create_params).execute + ) + results.append(f" - {format_permission_info(created_perm)}") + success_count += 1 + except HttpError as e: + results.append(f" - {identifier}: Failed - {str(e)}") + failure_count += 1 + + output_parts = [ + f"Batch share results for '{file_metadata.get('name', 'Unknown')}'", "", - f"View link: {file_metadata.get('webViewLink', 'N/A')}", + f"Summary: {success_count} succeeded, {failure_count} failed", + "", + "Results:", ] - ) + output_parts.extend(results) + output_parts.extend( + [ + "", + f"View link: {file_metadata.get('webViewLink', 'N/A')}", + ] + ) + return "\n".join(output_parts) - return "\n".join(output_parts) + # --- update: modify an existing permission --- + if action == "update": + if not permission_id: + raise ValueError("permission_id is required for 'update' action") + if not role and not expiration_time: + raise ValueError( + "Must provide at least one of: role, expiration_time for 'update' action" + ) + if role: + validate_share_role(role) + if expiration_time: + validate_expiration_time(expiration_time) -@server.tool() -@handle_http_errors("update_drive_permission", is_read_only=False, service_type="drive") -@require_google_service("drive", "drive_file") -async def update_drive_permission( - service, - user_google_email: str, - file_id: str, - permission_id: str, - role: Optional[str] = None, - expiration_time: Optional[str] = None, -) -> str: - """ - Updates an existing permission on a Google Drive file or folder. + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name" + ) + file_id = resolved_file_id - Args: - user_google_email (str): The user's Google email address. Required. - file_id (str): The ID of the file or folder. Required. - permission_id (str): The ID of the permission to update (from get_drive_file_permissions). Required. - role (Optional[str]): New role - 'reader', 'commenter', or 'writer'. If not provided, role unchanged. - expiration_time (Optional[str]): Expiration time in RFC 3339 format (e.g., "2025-01-15T00:00:00Z"). Set or update when permission expires. + effective_role = role + if not effective_role: + current_permission = await asyncio.to_thread( + service.permissions() + .get( + fileId=file_id, + permissionId=permission_id, + supportsAllDrives=True, + fields="role", + ) + .execute + ) + effective_role = current_permission.get("role") - Returns: - str: Confirmation with updated permission details. - """ - logger.info( - f"[update_drive_permission] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Permission ID: '{permission_id}', Role: '{role}'" - ) + update_body: Dict[str, Any] = {"role": effective_role} + if expiration_time: + update_body["expirationTime"] = expiration_time - if not role and not expiration_time: - raise ValueError("Must provide at least one of: role, expiration_time") - - if role: - validate_share_role(role) - if expiration_time: - validate_expiration_time(expiration_time) - - resolved_file_id, file_metadata = await resolve_drive_item( - service, file_id, extra_fields="name" - ) - file_id = resolved_file_id - - # Google API requires role in update body, so fetch current if not provided - if not role: - current_permission = await asyncio.to_thread( + updated_permission = await asyncio.to_thread( service.permissions() - .get( + .update( fileId=file_id, permissionId=permission_id, + body=update_body, supportsAllDrives=True, - fields="role", + fields="id, type, role, emailAddress, domain, expirationTime", ) .execute ) - role = current_permission.get("role") - update_body = {"role": role} - if expiration_time: - update_body["expirationTime"] = expiration_time + return "\n".join( + [ + f"Successfully updated permission on '{file_metadata.get('name', 'Unknown')}'", + "", + "Updated permission:", + f" - {format_permission_info(updated_permission)}", + ] + ) - updated_permission = await asyncio.to_thread( + # --- revoke: remove an existing permission --- + if action == "revoke": + if not permission_id: + raise ValueError("permission_id is required for 'revoke' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name" + ) + file_id = resolved_file_id + + await asyncio.to_thread( + service.permissions() + .delete( + fileId=file_id, + permissionId=permission_id, + supportsAllDrives=True, + ) + .execute + ) + + return "\n".join( + [ + f"Successfully removed permission from '{file_metadata.get('name', 'Unknown')}'", + "", + f"Permission ID '{permission_id}' has been revoked.", + ] + ) + + # --- transfer_owner: transfer file ownership --- + # action == "transfer_owner" + if not new_owner_email: + raise ValueError("new_owner_email is required for 'transfer_owner' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, owners" + ) + file_id = resolved_file_id + + current_owners = file_metadata.get("owners", []) + current_owner_emails = [o.get("emailAddress", "") for o in current_owners] + + transfer_body: Dict[str, Any] = { + "type": "user", + "role": "owner", + "emailAddress": new_owner_email, + } + + await asyncio.to_thread( service.permissions() - .update( + .create( fileId=file_id, - permissionId=permission_id, - body=update_body, + body=transfer_body, + transferOwnership=True, + moveToNewOwnersRoot=move_to_new_owners_root, supportsAllDrives=True, - fields="id, type, role, emailAddress, domain, expirationTime", + fields="id, type, role, emailAddress", ) .execute ) output_parts = [ - f"Successfully updated permission on '{file_metadata.get('name', 'Unknown')}'", + f"Successfully transferred ownership of '{file_metadata.get('name', 'Unknown')}'", "", - "Updated permission:", - f" - {format_permission_info(updated_permission)}", - ] - - return "\n".join(output_parts) - - -@server.tool() -@handle_http_errors("remove_drive_permission", is_read_only=False, service_type="drive") -@require_google_service("drive", "drive_file") -async def remove_drive_permission( - service, - user_google_email: str, - file_id: str, - permission_id: str, -) -> str: - """ - Removes a permission from a Google Drive file or folder, revoking access. - - Args: - user_google_email (str): The user's Google email address. Required. - file_id (str): The ID of the file or folder. Required. - permission_id (str): The ID of the permission to remove (from get_drive_file_permissions). Required. - - Returns: - str: Confirmation of the removed permission. - """ - logger.info( - f"[remove_drive_permission] Invoked. Email: '{user_google_email}', File ID: '{file_id}', Permission ID: '{permission_id}'" - ) - - resolved_file_id, file_metadata = await resolve_drive_item( - service, file_id, extra_fields="name" - ) - file_id = resolved_file_id - - await asyncio.to_thread( - service.permissions() - .delete(fileId=file_id, permissionId=permission_id, supportsAllDrives=True) - .execute - ) - - output_parts = [ - f"Successfully removed permission from '{file_metadata.get('name', 'Unknown')}'", - "", - f"Permission ID '{permission_id}' has been revoked.", + f"New owner: {new_owner_email}", + f"Previous owner(s): {', '.join(current_owner_emails) or 'Unknown'}", ] + if move_to_new_owners_root: + output_parts.append(f"File moved to {new_owner_email}'s My Drive root.") + output_parts.extend(["", "Note: Previous owner now has editor access."]) return "\n".join(output_parts) @@ -2209,79 +2212,6 @@ async def copy_drive_file( return "\n".join(output_parts) -@server.tool() -@handle_http_errors( - "transfer_drive_ownership", is_read_only=False, service_type="drive" -) -@require_google_service("drive", "drive_file") -async def transfer_drive_ownership( - service, - user_google_email: str, - file_id: str, - new_owner_email: str, - move_to_new_owners_root: bool = False, -) -> str: - """ - Transfers ownership of a Google Drive file or folder to another user. - - This is an irreversible operation. The current owner will become an editor. - Only works within the same Google Workspace domain or for personal accounts. - - Args: - user_google_email (str): The user's Google email address. Required. - file_id (str): The ID of the file or folder to transfer. Required. - new_owner_email (str): Email address of the new owner. Required. - move_to_new_owners_root (bool): If True, moves the file to the new owner's My Drive root. Defaults to False. - - Returns: - str: Confirmation of the ownership transfer. - """ - logger.info( - f"[transfer_drive_ownership] Invoked. Email: '{user_google_email}', File ID: '{file_id}', New owner: '{new_owner_email}'" - ) - - resolved_file_id, file_metadata = await resolve_drive_item( - service, file_id, extra_fields="name, owners" - ) - file_id = resolved_file_id - - current_owners = file_metadata.get("owners", []) - current_owner_emails = [o.get("emailAddress", "") for o in current_owners] - - permission_body = { - "type": "user", - "role": "owner", - "emailAddress": new_owner_email, - } - - await asyncio.to_thread( - service.permissions() - .create( - fileId=file_id, - body=permission_body, - transferOwnership=True, - moveToNewOwnersRoot=move_to_new_owners_root, - supportsAllDrives=True, - fields="id, type, role, emailAddress", - ) - .execute - ) - - output_parts = [ - f"Successfully transferred ownership of '{file_metadata.get('name', 'Unknown')}'", - "", - f"New owner: {new_owner_email}", - f"Previous owner(s): {', '.join(current_owner_emails) or 'Unknown'}", - ] - - if move_to_new_owners_root: - output_parts.append(f"File moved to {new_owner_email}'s My Drive root.") - - output_parts.extend(["", "Note: Previous owner now has editor access."]) - - return "\n".join(output_parts) - - @server.tool() @handle_http_errors( "set_drive_file_permissions", is_read_only=False, service_type="drive" diff --git a/gmail/gmail_tools.py b/gmail/gmail_tools.py index a56034a..ff875d5 100644 --- a/gmail/gmail_tools.py +++ b/gmail/gmail_tools.py @@ -19,9 +19,10 @@ from email import encoders from email.utils import formataddr from pydantic import Field +from googleapiclient.errors import HttpError from auth.service_decorator import require_google_service -from core.utils import handle_http_errors, validate_file_path +from core.utils import handle_http_errors, validate_file_path, UserInputError from core.server import server from auth.scopes import ( GMAIL_SEND_SCOPE, @@ -172,6 +173,74 @@ def _format_body_content(text_body: str, html_body: str) -> str: return "[No readable content found]" +def _append_signature_to_body( + body: str, body_format: Literal["plain", "html"], signature_html: str +) -> str: + """Append a Gmail signature to the outgoing body, preserving body format.""" + if not signature_html or not signature_html.strip(): + return body + + if body_format == "html": + separator = "

" if body.strip() else "" + return f"{body}{separator}{signature_html}" + + signature_text = _html_to_text(signature_html).strip() + if not signature_text: + return body + separator = "\n\n" if body.strip() else "" + return f"{body}{separator}{signature_text}" + + +async def _get_send_as_signature_html(service, from_email: Optional[str] = None) -> str: + """ + Fetch signature HTML from Gmail send-as settings. + + Returns empty string when the account has no signature configured or the + OAuth token cannot access settings endpoints. + """ + try: + response = await asyncio.to_thread( + service.users().settings().sendAs().list(userId="me").execute + ) + except HttpError as e: + status = getattr(getattr(e, "resp", None), "status", None) + if status in {401, 403}: + logger.info( + "Skipping Gmail signature fetch: missing auth/scope for settings endpoint." + ) + return "" + logger.warning(f"Failed to fetch Gmail send-as signatures: {e}") + return "" + except Exception as e: + logger.warning(f"Failed to fetch Gmail send-as signatures: {e}") + return "" + + send_as_entries = response.get("sendAs", []) + if not send_as_entries: + return "" + + if from_email: + from_email_normalized = from_email.strip().lower() + for entry in send_as_entries: + if entry.get("sendAsEmail", "").strip().lower() == from_email_normalized: + return entry.get("signature", "") or "" + + for entry in send_as_entries: + if entry.get("isPrimary"): + return entry.get("signature", "") or "" + + return send_as_entries[0].get("signature", "") or "" + + +def _format_attachment_result(attached_count: int, requested_count: int) -> str: + """Format attachment result message for user-facing responses.""" + if requested_count <= 0: + return "" + if attached_count == requested_count: + return f" with {attached_count} attachment(s)" + return f" with {attached_count}/{requested_count} attachment(s) attached" + + def _extract_attachments(payload: dict) -> List[Dict[str, Any]]: """ Extract attachment metadata from a Gmail message payload. @@ -241,7 +310,7 @@ def _prepare_gmail_message( from_email: Optional[str] = None, from_name: Optional[str] = None, attachments: Optional[List[Dict[str, str]]] = None, -) -> tuple[str, Optional[str]]: +) -> tuple[str, Optional[str], int]: """ Prepare a Gmail message with threading and attachment support. @@ -260,7 +329,7 @@ def _prepare_gmail_message( attachments: Optional list of attachments. Each can have 'path' (file path) OR 'content' (base64) + 'filename' Returns: - Tuple of (raw_message, thread_id) where raw_message is base64 encoded + Tuple of (raw_message, thread_id, attached_count) where raw_message is base64 encoded """ # Handle reply subject formatting reply_subject = subject @@ -273,6 +342,7 @@ def _prepare_gmail_message( raise ValueError("body_format must be either 'plain' or 'html'.") # Use multipart if attachments are provided + attached_count = 0 if attachments: message = MIMEMultipart() message.attach(MIMEText(body, normalized_format)) @@ -344,6 +414,7 @@ def _prepare_gmail_message( ) message.attach(part) + attached_count += 1 logger.info(f"Attached file: {filename} ({len(file_data)} bytes)") except Exception as e: logger.error(f"Failed to attach {filename or file_path}: {e}") @@ -382,7 +453,7 @@ def _prepare_gmail_message( # Encode message raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode() - return raw_message, thread_id + return raw_message, thread_id, attached_count def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str: @@ -1177,7 +1248,7 @@ async def send_gmail_message( # Prepare the email message # Use from_email (Send As alias) if provided, otherwise default to authenticated user sender_email = from_email or user_google_email - raw_message, thread_id_final = _prepare_gmail_message( + raw_message, thread_id_final, attached_count = _prepare_gmail_message( subject=subject, body=body, to=to, @@ -1192,6 +1263,12 @@ async def send_gmail_message( attachments=attachments if attachments else None, ) + requested_attachment_count = len(attachments or []) + if requested_attachment_count > 0 and attached_count == 0: + raise UserInputError( + "No valid attachments were added. Verify each attachment path/content and retry." + ) + send_body = {"raw": raw_message} # Associate with thread if provided @@ -1204,8 +1281,11 @@ async def send_gmail_message( ) message_id = sent_message.get("id") - if attachments: - return f"Email sent with {len(attachments)} attachment(s)! Message ID: {message_id}" + if requested_attachment_count > 0: + attachment_info = _format_attachment_result( + attached_count, requested_attachment_count + ) + return f"Email sent{attachment_info}! Message ID: {message_id}" return f"Email sent! Message ID: {message_id}" @@ -1271,6 +1351,12 @@ async def draft_gmail_message( description="Optional list of attachments. Each can have: 'path' (file path, auto-encodes), OR 'content' (standard base64, not urlsafe) + 'filename'. Optional 'mime_type' (auto-detected from path if not provided).", ), ] = None, + include_signature: Annotated[ + bool, + Field( + description="Whether to append the Gmail signature from Settings > Signature when available. Defaults to true.", + ), + ] = True, ) -> str: """ Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments. @@ -1300,6 +1386,8 @@ async def draft_gmail_message( - 'content' (required): Standard base64-encoded file content (not urlsafe) - 'filename' (required): Name of the file - 'mime_type' (optional): MIME type (defaults to 'application/octet-stream') + include_signature (bool): Whether to append Gmail signature HTML from send-as settings. + If unavailable (e.g., missing gmail.settings.basic scope), the draft is still created without signature. Returns: str: Confirmation message with the created draft's ID. @@ -1363,9 +1451,16 @@ async def draft_gmail_message( # Prepare the email message # Use from_email (Send As alias) if provided, otherwise default to authenticated user sender_email = from_email or user_google_email - raw_message, thread_id_final = _prepare_gmail_message( + draft_body = body + if include_signature: + signature_html = await _get_send_as_signature_html( + service, from_email=sender_email + ) + draft_body = _append_signature_to_body(draft_body, body_format, signature_html) + + raw_message, thread_id_final, attached_count = _prepare_gmail_message( subject=subject, - body=body, + body=draft_body, body_format=body_format, to=to, cc=cc, @@ -1378,6 +1473,12 @@ async def draft_gmail_message( attachments=attachments, ) + requested_attachment_count = len(attachments or []) + if requested_attachment_count > 0 and attached_count == 0: + raise UserInputError( + "No valid attachments were added. Verify each attachment path/content and retry." + ) + # Create a draft instead of sending draft_body = {"message": {"raw": raw_message}} @@ -1390,7 +1491,9 @@ async def draft_gmail_message( service.users().drafts().create(userId="me", body=draft_body).execute ) draft_id = created_draft.get("id") - attachment_info = f" with {len(attachments)} attachment(s)" if attachments else "" + attachment_info = _format_attachment_result( + attached_count, requested_attachment_count + ) return f"Draft created{attachment_info}! Draft ID: {draft_id}" @@ -1822,88 +1925,72 @@ async def list_gmail_filters(service, user_google_email: str) -> str: @server.tool() -@handle_http_errors("create_gmail_filter", service_type="gmail") +@handle_http_errors("manage_gmail_filter", service_type="gmail") @require_google_service("gmail", "gmail_settings_basic") -async def create_gmail_filter( +async def manage_gmail_filter( service, user_google_email: str, - criteria: Annotated[ - Dict[str, Any], - Field( - description="Filter criteria object as defined in the Gmail API.", - ), - ], - action: Annotated[ - Dict[str, Any], - Field( - description="Filter action object as defined in the Gmail API.", - ), - ], + action: str, + criteria: Optional[Dict[str, Any]] = None, + filter_action: Optional[Dict[str, Any]] = None, + filter_id: Optional[str] = None, ) -> str: """ - Creates a Gmail filter using the users.settings.filters API. + Manages Gmail filters. Supports creating and deleting filters. Args: user_google_email (str): The user's Google email address. Required. - criteria (Dict[str, Any]): Criteria for matching messages. - action (Dict[str, Any]): Actions to apply to matched messages. + action (str): Action to perform - "create" or "delete". + criteria (Optional[Dict[str, Any]]): Filter criteria object (required for create). + filter_action (Optional[Dict[str, Any]]): Filter action object (required for create). Named 'filter_action' to avoid shadowing the 'action' parameter. + filter_id (Optional[str]): ID of the filter to delete (required for delete). Returns: - str: Confirmation message with the created filter ID. + str: Confirmation message with filter details. """ - logger.info("[create_gmail_filter] Invoked") - - filter_body = {"criteria": criteria, "action": action} - - created_filter = await asyncio.to_thread( - service.users() - .settings() - .filters() - .create(userId="me", body=filter_body) - .execute - ) - - filter_id = created_filter.get("id", "(unknown)") - return f"Filter created successfully!\nFilter ID: {filter_id}" - - -@server.tool() -@handle_http_errors("delete_gmail_filter", service_type="gmail") -@require_google_service("gmail", "gmail_settings_basic") -async def delete_gmail_filter( - service, - user_google_email: str, - filter_id: str = Field(..., description="ID of the filter to delete."), -) -> str: - """ - Deletes a Gmail filter by ID. - - Args: - user_google_email (str): The user's Google email address. Required. - filter_id (str): The ID of the filter to delete. - - Returns: - str: Confirmation message for the deletion. - """ - logger.info(f"[delete_gmail_filter] Invoked. Filter ID: '{filter_id}'") - - filter_details = await asyncio.to_thread( - service.users().settings().filters().get(userId="me", id=filter_id).execute - ) - - await asyncio.to_thread( - service.users().settings().filters().delete(userId="me", id=filter_id).execute - ) - - criteria = filter_details.get("criteria", {}) - action = filter_details.get("action", {}) - - return ( - "Filter deleted successfully!\n" - f"Filter ID: {filter_id}\n" - f"Criteria: {criteria or '(none)'}\n" - f"Action: {action or '(none)'}" - ) + action_lower = action.lower().strip() + if action_lower == "create": + if not criteria or not filter_action: + raise ValueError( + "criteria and filter_action are required for create action" + ) + logger.info("[manage_gmail_filter] Creating filter") + filter_body = {"criteria": criteria, "action": filter_action} + created_filter = await asyncio.to_thread( + service.users() + .settings() + .filters() + .create(userId="me", body=filter_body) + .execute + ) + fid = created_filter.get("id", "(unknown)") + return f"Filter created successfully!\nFilter ID: {fid}" + elif action_lower == "delete": + if not filter_id: + raise ValueError("filter_id is required for delete action") + logger.info(f"[manage_gmail_filter] Deleting filter {filter_id}") + filter_details = await asyncio.to_thread( + service.users().settings().filters().get(userId="me", id=filter_id).execute + ) + await asyncio.to_thread( + service.users() + .settings() + .filters() + .delete(userId="me", id=filter_id) + .execute + ) + criteria_info = filter_details.get("criteria", {}) + action_info = filter_details.get("action", {}) + return ( + "Filter deleted successfully!\n" + f"Filter ID: {filter_id}\n" + f"Criteria: {criteria_info or '(none)'}\n" + f"Action: {action_info or '(none)'}" + ) + else: + raise ValueError( + f"Invalid action '{action_lower}'. Must be 'create' or 'delete'." + ) @server.tool() diff --git a/gsearch/search_tools.py b/gsearch/search_tools.py index 6afb0fd..3e4694a 100644 --- a/gsearch/search_tools.py +++ b/gsearch/search_tools.py @@ -33,6 +33,7 @@ async def search_custom( file_type: Optional[str] = None, language: Optional[str] = None, country: Optional[str] = None, + sites: Optional[List[str]] = None, ) -> str: """ Performs a search using Google Custom Search JSON API. @@ -50,6 +51,7 @@ async def search_custom( file_type (Optional[str]): Filter by file type (e.g., "pdf", "doc"). language (Optional[str]): Language code for results (e.g., "lang_en"). country (Optional[str]): Country code for results (e.g., "countryUS"). + sites (Optional[List[str]]): List of sites/domains to restrict search to (e.g., ["example.com", "docs.example.com"]). When provided, results are limited to these sites. Returns: str: Formatted search results including title, link, and snippet for each result. @@ -71,6 +73,12 @@ async def search_custom( f"[search_custom] Invoked. Email: '{user_google_email}', Query: '{q}', CX: '{cx}'" ) + # Apply site restriction if sites are provided + if sites: + site_query = " OR ".join([f"site:{site}" for site in sites]) + q = f"{q} ({site_query})" + logger.info(f"[search_custom] Applied site restriction: {sites}") + # Build the request parameters params = { "key": api_key, @@ -224,50 +232,3 @@ async def get_search_engine_info(service, user_google_email: str) -> str: logger.info(f"Search engine info retrieved successfully for {user_google_email}") return confirmation_message - - -@server.tool() -@handle_http_errors( - "search_custom_siterestrict", is_read_only=True, service_type="customsearch" -) -@require_google_service("customsearch", "customsearch") -async def search_custom_siterestrict( - service, - user_google_email: str, - q: str, - sites: List[str], - num: int = 10, - start: int = 1, - safe: Literal["active", "moderate", "off"] = "off", -) -> str: - """ - Performs a search restricted to specific sites using Google Custom Search. - - Args: - user_google_email (str): The user's Google email address. Required. - q (str): The search query. Required. - sites (List[str]): List of sites/domains to search within. - num (int): Number of results to return (1-10). Defaults to 10. - start (int): The index of the first result to return (1-based). Defaults to 1. - safe (Literal["active", "moderate", "off"]): Safe search level. Defaults to "off". - - Returns: - str: Formatted search results from the specified sites. - """ - logger.info( - f"[search_custom_siterestrict] Invoked. Email: '{user_google_email}', Query: '{q}', Sites: {sites}" - ) - - # Build site restriction query - site_query = " OR ".join([f"site:{site}" for site in sites]) - full_query = f"{q} ({site_query})" - - # Use the main search function with the modified query - return await search_custom( - service=service, - user_google_email=user_google_email, - q=full_query, - num=num, - start=start, - safe=safe, - ) diff --git a/gsheets/sheets_tools.py b/gsheets/sheets_tools.py index ef9501d..04676f9 100644 --- a/gsheets/sheets_tools.py +++ b/gsheets/sheets_tools.py @@ -729,405 +729,401 @@ async def format_sheet_range( @server.tool() -@handle_http_errors("add_conditional_formatting", service_type="sheets") +@handle_http_errors("manage_conditional_formatting", service_type="sheets") @require_google_service("sheets", "sheets_write") -async def add_conditional_formatting( +async def manage_conditional_formatting( service, user_google_email: str, spreadsheet_id: str, - range_name: str, - condition_type: str, - condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None, - background_color: Optional[str] = None, - text_color: Optional[str] = None, - rule_index: Optional[int] = None, - gradient_points: Optional[Union[str, List[dict]]] = None, -) -> str: - """ - Adds a conditional formatting rule to a range. - - Args: - user_google_email (str): The user's Google email address. Required. - spreadsheet_id (str): The ID of the spreadsheet. Required. - range_name (str): A1-style range (optionally with sheet name). Required. - condition_type (str): Sheets condition type (e.g., NUMBER_GREATER, TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA). - condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition; accepts a list or a JSON string representing a list. Depends on condition_type. - background_color (Optional[str]): Hex background color to apply when condition matches. - text_color (Optional[str]): Hex text color to apply when condition matches. - rule_index (Optional[int]): Optional position to insert the rule (0-based) within the sheet's rules. - gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of gradient points for a color scale. If provided, a gradient rule is created and boolean parameters are ignored. - - Returns: - str: Confirmation of the added rule. - """ - logger.info( - "[add_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Type: %s, Values: %s", - user_google_email, - spreadsheet_id, - range_name, - condition_type, - condition_values, - ) - - if rule_index is not None and (not isinstance(rule_index, int) or rule_index < 0): - raise UserInputError("rule_index must be a non-negative integer when provided.") - - condition_values_list = _parse_condition_values(condition_values) - gradient_points_list = _parse_gradient_points(gradient_points) - - sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) - grid_range = _parse_a1_range(range_name, sheets) - - target_sheet = None - for sheet in sheets: - if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"): - target_sheet = sheet - break - if target_sheet is None: - raise UserInputError( - "Target sheet not found while adding conditional formatting." - ) - - current_rules = target_sheet.get("conditionalFormats", []) or [] - - insert_at = rule_index if rule_index is not None else len(current_rules) - if insert_at > len(current_rules): - raise UserInputError( - f"rule_index {insert_at} is out of range for sheet '{target_sheet.get('properties', {}).get('title', 'Unknown')}' " - f"(current count: {len(current_rules)})." - ) - - if gradient_points_list: - new_rule = _build_gradient_rule([grid_range], gradient_points_list) - rule_desc = "gradient" - values_desc = "" - applied_parts = [f"gradient points {len(gradient_points_list)}"] - else: - rule, cond_type_normalized = _build_boolean_rule( - [grid_range], - condition_type, - condition_values_list, - background_color, - text_color, - ) - new_rule = rule - rule_desc = cond_type_normalized - values_desc = "" - if condition_values_list: - values_desc = f" with values {condition_values_list}" - applied_parts = [] - if background_color: - applied_parts.append(f"background {background_color}") - if text_color: - applied_parts.append(f"text {text_color}") - - new_rules_state = copy.deepcopy(current_rules) - new_rules_state.insert(insert_at, new_rule) - - add_rule_request = {"rule": new_rule} - if rule_index is not None: - add_rule_request["index"] = rule_index - - request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]} - - await asyncio.to_thread( - service.spreadsheets() - .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) - .execute - ) - - format_desc = ", ".join(applied_parts) if applied_parts else "format applied" - - sheet_title = target_sheet.get("properties", {}).get("title", "Unknown") - state_text = _format_conditional_rules_section( - sheet_title, new_rules_state, sheet_titles, indent="" - ) - - return "\n".join( - [ - f"Added conditional format on '{range_name}' in spreadsheet {spreadsheet_id} " - f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.", - state_text, - ] - ) - - -@server.tool() -@handle_http_errors("update_conditional_formatting", service_type="sheets") -@require_google_service("sheets", "sheets_write") -async def update_conditional_formatting( - service, - user_google_email: str, - spreadsheet_id: str, - rule_index: int, + action: str, range_name: Optional[str] = None, condition_type: Optional[str] = None, condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None, background_color: Optional[str] = None, text_color: Optional[str] = None, - sheet_name: Optional[str] = None, + rule_index: Optional[int] = None, gradient_points: Optional[Union[str, List[dict]]] = None, + sheet_name: Optional[str] = None, ) -> str: """ - Updates an existing conditional formatting rule by index on a sheet. + Manages conditional formatting rules on a Google Sheet. Supports adding, + updating, and deleting conditional formatting rules via a single tool. Args: user_google_email (str): The user's Google email address. Required. spreadsheet_id (str): The ID of the spreadsheet. Required. - range_name (Optional[str]): A1-style range to apply the updated rule (optionally with sheet name). If omitted, existing ranges are preserved. - rule_index (int): Index of the rule to update (0-based). - condition_type (Optional[str]): Sheets condition type. If omitted, the existing rule's type is preserved. - condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values for the condition. - background_color (Optional[str]): Hex background color when condition matches. - text_color (Optional[str]): Hex text color when condition matches. - sheet_name (Optional[str]): Sheet name to locate the rule when range_name is omitted. Defaults to first sheet. - gradient_points (Optional[Union[str, List[dict]]]): If provided, updates the rule to a gradient color scale using these points. + action (str): The operation to perform. Must be one of "add", "update", + or "delete". + range_name (Optional[str]): A1-style range (optionally with sheet name). + Required for "add". Optional for "update" (preserves existing ranges + if omitted). Not used for "delete". + condition_type (Optional[str]): Sheets condition type (e.g., NUMBER_GREATER, + TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA). Required for "add". + Optional for "update" (preserves existing type if omitted). + condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values + for the condition; accepts a list or a JSON string representing a list. + Depends on condition_type. Used by "add" and "update". + background_color (Optional[str]): Hex background color to apply when + condition matches. Used by "add" and "update". + text_color (Optional[str]): Hex text color to apply when condition matches. + Used by "add" and "update". + rule_index (Optional[int]): 0-based index of the rule. For "add", optionally + specifies insertion position. Required for "update" and "delete". + gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of + gradient points for a color scale. If provided, a gradient rule is created + and boolean parameters are ignored. Used by "add" and "update". + sheet_name (Optional[str]): Sheet name to locate the rule when range_name is + omitted. Defaults to the first sheet. Used by "update" and "delete". Returns: - str: Confirmation of the updated rule and the current rule state. + str: Confirmation of the operation and the current rule state. """ + allowed_actions = {"add", "update", "delete"} + action_normalized = action.strip().lower() + if action_normalized not in allowed_actions: + raise UserInputError( + f"action must be one of {sorted(allowed_actions)}, got '{action}'." + ) + logger.info( - "[update_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Range: %s, Rule Index: %s", + "[manage_conditional_formatting] Invoked. Action: '%s', Email: '%s', Spreadsheet: %s", + action_normalized, user_google_email, spreadsheet_id, - range_name, - rule_index, ) - if not isinstance(rule_index, int) or rule_index < 0: - raise UserInputError("rule_index must be a non-negative integer.") + if action_normalized == "add": + if not range_name: + raise UserInputError("range_name is required for action 'add'.") + if not condition_type and not gradient_points: + raise UserInputError( + "condition_type (or gradient_points) is required for action 'add'." + ) - condition_values_list = _parse_condition_values(condition_values) - gradient_points_list = _parse_gradient_points(gradient_points) + if rule_index is not None and ( + not isinstance(rule_index, int) or rule_index < 0 + ): + raise UserInputError( + "rule_index must be a non-negative integer when provided." + ) - sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) + gradient_points_list = _parse_gradient_points(gradient_points) + condition_values_list = ( + None if gradient_points_list else _parse_condition_values(condition_values) + ) - target_sheet = None - grid_range = None - if range_name: + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) grid_range = _parse_a1_range(range_name, sheets) + + target_sheet = None for sheet in sheets: if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"): target_sheet = sheet break - else: + if target_sheet is None: + raise UserInputError( + "Target sheet not found while adding conditional formatting." + ) + + current_rules = target_sheet.get("conditionalFormats", []) or [] + + insert_at = rule_index if rule_index is not None else len(current_rules) + if insert_at > len(current_rules): + raise UserInputError( + f"rule_index {insert_at} is out of range for sheet " + f"'{target_sheet.get('properties', {}).get('title', 'Unknown')}' " + f"(current count: {len(current_rules)})." + ) + + if gradient_points_list: + new_rule = _build_gradient_rule([grid_range], gradient_points_list) + rule_desc = "gradient" + values_desc = "" + applied_parts = [f"gradient points {len(gradient_points_list)}"] + else: + rule, cond_type_normalized = _build_boolean_rule( + [grid_range], + condition_type, + condition_values_list, + background_color, + text_color, + ) + new_rule = rule + rule_desc = cond_type_normalized + values_desc = "" + if condition_values_list: + values_desc = f" with values {condition_values_list}" + applied_parts = [] + if background_color: + applied_parts.append(f"background {background_color}") + if text_color: + applied_parts.append(f"text {text_color}") + + new_rules_state = copy.deepcopy(current_rules) + new_rules_state.insert(insert_at, new_rule) + + add_rule_request = {"rule": new_rule} + if rule_index is not None: + add_rule_request["index"] = rule_index + + request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]} + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + format_desc = ", ".join(applied_parts) if applied_parts else "format applied" + + sheet_title = target_sheet.get("properties", {}).get("title", "Unknown") + state_text = _format_conditional_rules_section( + sheet_title, new_rules_state, sheet_titles, indent="" + ) + + return "\n".join( + [ + f"Added conditional format on '{range_name}' in spreadsheet " + f"{spreadsheet_id} for {user_google_email}: " + f"{rule_desc}{values_desc}; format: {format_desc}.", + state_text, + ] + ) + + elif action_normalized == "update": + if rule_index is None: + raise UserInputError("rule_index is required for action 'update'.") + if not isinstance(rule_index, int) or rule_index < 0: + raise UserInputError("rule_index must be a non-negative integer.") + + gradient_points_list = _parse_gradient_points(gradient_points) + condition_values_list = ( + None + if gradient_points_list is not None + else _parse_condition_values(condition_values) + ) + + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) + + target_sheet = None + grid_range = None + if range_name: + grid_range = _parse_a1_range(range_name, sheets) + for sheet in sheets: + if sheet.get("properties", {}).get("sheetId") == grid_range.get( + "sheetId" + ): + target_sheet = sheet + break + else: + target_sheet = _select_sheet(sheets, sheet_name) + + if target_sheet is None: + raise UserInputError( + "Target sheet not found while updating conditional formatting." + ) + + sheet_props = target_sheet.get("properties", {}) + sheet_id = sheet_props.get("sheetId") + sheet_title = sheet_props.get("title", f"Sheet {sheet_id}") + + rules = target_sheet.get("conditionalFormats", []) or [] + if rule_index >= len(rules): + raise UserInputError( + f"rule_index {rule_index} is out of range for sheet " + f"'{sheet_title}' (current count: {len(rules)})." + ) + + existing_rule = rules[rule_index] + ranges_to_use = existing_rule.get("ranges", []) + if range_name: + ranges_to_use = [grid_range] + if not ranges_to_use: + ranges_to_use = [{"sheetId": sheet_id}] + + new_rule = None + rule_desc = "" + values_desc = "" + format_desc = "" + + if gradient_points_list is not None: + new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list) + rule_desc = "gradient" + format_desc = f"gradient points {len(gradient_points_list)}" + elif "gradientRule" in existing_rule: + if any( + [ + background_color, + text_color, + condition_type, + condition_values_list, + ] + ): + raise UserInputError( + "Existing rule is a gradient rule. Provide gradient_points " + "to update it, or omit formatting/condition parameters to " + "keep it unchanged." + ) + new_rule = { + "ranges": ranges_to_use, + "gradientRule": existing_rule.get("gradientRule", {}), + } + rule_desc = "gradient" + format_desc = "gradient (unchanged)" + else: + existing_boolean = existing_rule.get("booleanRule", {}) + existing_condition = existing_boolean.get("condition", {}) + existing_format = copy.deepcopy(existing_boolean.get("format", {})) + + cond_type = (condition_type or existing_condition.get("type", "")).upper() + if not cond_type: + raise UserInputError("condition_type is required for boolean rules.") + if cond_type not in CONDITION_TYPES: + raise UserInputError( + f"condition_type must be one of {sorted(CONDITION_TYPES)}." + ) + + if condition_values_list is not None: + cond_values = [ + {"userEnteredValue": str(val)} for val in condition_values_list + ] + else: + cond_values = existing_condition.get("values") + + new_format = copy.deepcopy(existing_format) if existing_format else {} + if background_color is not None: + bg_color_parsed = _parse_hex_color(background_color) + if bg_color_parsed: + new_format["backgroundColor"] = bg_color_parsed + elif "backgroundColor" in new_format: + del new_format["backgroundColor"] + if text_color is not None: + text_color_parsed = _parse_hex_color(text_color) + text_format = copy.deepcopy(new_format.get("textFormat", {})) + if text_color_parsed: + text_format["foregroundColor"] = text_color_parsed + elif "foregroundColor" in text_format: + del text_format["foregroundColor"] + if text_format: + new_format["textFormat"] = text_format + elif "textFormat" in new_format: + del new_format["textFormat"] + + if not new_format: + raise UserInputError( + "At least one format option must remain on the rule." + ) + + new_rule = { + "ranges": ranges_to_use, + "booleanRule": { + "condition": {"type": cond_type}, + "format": new_format, + }, + } + if cond_values: + new_rule["booleanRule"]["condition"]["values"] = cond_values + + rule_desc = cond_type + if condition_values_list: + values_desc = f" with values {condition_values_list}" + format_parts = [] + if "backgroundColor" in new_format: + format_parts.append("background updated") + if "textFormat" in new_format and new_format["textFormat"].get( + "foregroundColor" + ): + format_parts.append("text color updated") + format_desc = ( + ", ".join(format_parts) if format_parts else "format preserved" + ) + + new_rules_state = copy.deepcopy(rules) + new_rules_state[rule_index] = new_rule + + request_body = { + "requests": [ + { + "updateConditionalFormatRule": { + "index": rule_index, + "sheetId": sheet_id, + "rule": new_rule, + } + } + ] + } + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + state_text = _format_conditional_rules_section( + sheet_title, new_rules_state, sheet_titles, indent="" + ) + + return "\n".join( + [ + f"Updated conditional format at index {rule_index} on sheet " + f"'{sheet_title}' in spreadsheet {spreadsheet_id} " + f"for {user_google_email}: " + f"{rule_desc}{values_desc}; format: {format_desc}.", + state_text, + ] + ) + + else: # action_normalized == "delete" + if rule_index is None: + raise UserInputError("rule_index is required for action 'delete'.") + if not isinstance(rule_index, int) or rule_index < 0: + raise UserInputError("rule_index must be a non-negative integer.") + + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) target_sheet = _select_sheet(sheets, sheet_name) - if target_sheet is None: - raise UserInputError( - "Target sheet not found while updating conditional formatting." - ) - - sheet_props = target_sheet.get("properties", {}) - sheet_id = sheet_props.get("sheetId") - sheet_title = sheet_props.get("title", f"Sheet {sheet_id}") - - rules = target_sheet.get("conditionalFormats", []) or [] - if rule_index >= len(rules): - raise UserInputError( - f"rule_index {rule_index} is out of range for sheet '{sheet_title}' (current count: {len(rules)})." - ) - - existing_rule = rules[rule_index] - ranges_to_use = existing_rule.get("ranges", []) - if range_name: - ranges_to_use = [grid_range] - if not ranges_to_use: - ranges_to_use = [{"sheetId": sheet_id}] - - new_rule = None - rule_desc = "" - values_desc = "" - format_desc = "" - - if gradient_points_list is not None: - new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list) - rule_desc = "gradient" - format_desc = f"gradient points {len(gradient_points_list)}" - elif "gradientRule" in existing_rule: - if any([background_color, text_color, condition_type, condition_values_list]): + sheet_props = target_sheet.get("properties", {}) + sheet_id = sheet_props.get("sheetId") + target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}") + rules = target_sheet.get("conditionalFormats", []) or [] + if rule_index >= len(rules): raise UserInputError( - "Existing rule is a gradient rule. Provide gradient_points to update it, or omit formatting/condition parameters to keep it unchanged." - ) - new_rule = { - "ranges": ranges_to_use, - "gradientRule": existing_rule.get("gradientRule", {}), - } - rule_desc = "gradient" - format_desc = "gradient (unchanged)" - else: - existing_boolean = existing_rule.get("booleanRule", {}) - existing_condition = existing_boolean.get("condition", {}) - existing_format = copy.deepcopy(existing_boolean.get("format", {})) - - cond_type = (condition_type or existing_condition.get("type", "")).upper() - if not cond_type: - raise UserInputError("condition_type is required for boolean rules.") - if cond_type not in CONDITION_TYPES: - raise UserInputError( - f"condition_type must be one of {sorted(CONDITION_TYPES)}." + f"rule_index {rule_index} is out of range for sheet " + f"'{target_sheet_name}' (current count: {len(rules)})." ) - if condition_values_list is not None: - cond_values = [ - {"userEnteredValue": str(val)} for val in condition_values_list + new_rules_state = copy.deepcopy(rules) + del new_rules_state[rule_index] + + request_body = { + "requests": [ + { + "deleteConditionalFormatRule": { + "index": rule_index, + "sheetId": sheet_id, + } + } ] - else: - cond_values = existing_condition.get("values") - - new_format = copy.deepcopy(existing_format) if existing_format else {} - if background_color is not None: - bg_color_parsed = _parse_hex_color(background_color) - if bg_color_parsed: - new_format["backgroundColor"] = bg_color_parsed - elif "backgroundColor" in new_format: - del new_format["backgroundColor"] - if text_color is not None: - text_color_parsed = _parse_hex_color(text_color) - text_format = copy.deepcopy(new_format.get("textFormat", {})) - if text_color_parsed: - text_format["foregroundColor"] = text_color_parsed - elif "foregroundColor" in text_format: - del text_format["foregroundColor"] - if text_format: - new_format["textFormat"] = text_format - elif "textFormat" in new_format: - del new_format["textFormat"] - - if not new_format: - raise UserInputError("At least one format option must remain on the rule.") - - new_rule = { - "ranges": ranges_to_use, - "booleanRule": { - "condition": {"type": cond_type}, - "format": new_format, - }, } - if cond_values: - new_rule["booleanRule"]["condition"]["values"] = cond_values - rule_desc = cond_type - if condition_values_list: - values_desc = f" with values {condition_values_list}" - format_parts = [] - if "backgroundColor" in new_format: - format_parts.append("background updated") - if "textFormat" in new_format and new_format["textFormat"].get( - "foregroundColor" - ): - format_parts.append("text color updated") - format_desc = ", ".join(format_parts) if format_parts else "format preserved" - - new_rules_state = copy.deepcopy(rules) - new_rules_state[rule_index] = new_rule - - request_body = { - "requests": [ - { - "updateConditionalFormatRule": { - "index": rule_index, - "sheetId": sheet_id, - "rule": new_rule, - } - } - ] - } - - await asyncio.to_thread( - service.spreadsheets() - .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) - .execute - ) - - state_text = _format_conditional_rules_section( - sheet_title, new_rules_state, sheet_titles, indent="" - ) - - return "\n".join( - [ - f"Updated conditional format at index {rule_index} on sheet '{sheet_title}' in spreadsheet {spreadsheet_id} " - f"for {user_google_email}: {rule_desc}{values_desc}; format: {format_desc}.", - state_text, - ] - ) - - -@server.tool() -@handle_http_errors("delete_conditional_formatting", service_type="sheets") -@require_google_service("sheets", "sheets_write") -async def delete_conditional_formatting( - service, - user_google_email: str, - spreadsheet_id: str, - rule_index: int, - sheet_name: Optional[str] = None, -) -> str: - """ - Deletes an existing conditional formatting rule by index on a sheet. - - Args: - user_google_email (str): The user's Google email address. Required. - spreadsheet_id (str): The ID of the spreadsheet. Required. - rule_index (int): Index of the rule to delete (0-based). - sheet_name (Optional[str]): Name of the sheet that contains the rule. Defaults to the first sheet if not provided. - - Returns: - str: Confirmation of the deletion and the current rule state. - """ - logger.info( - "[delete_conditional_formatting] Invoked. Email: '%s', Spreadsheet: %s, Sheet: %s, Rule Index: %s", - user_google_email, - spreadsheet_id, - sheet_name, - rule_index, - ) - - if not isinstance(rule_index, int) or rule_index < 0: - raise UserInputError("rule_index must be a non-negative integer.") - - sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) - target_sheet = _select_sheet(sheets, sheet_name) - - sheet_props = target_sheet.get("properties", {}) - sheet_id = sheet_props.get("sheetId") - target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}") - rules = target_sheet.get("conditionalFormats", []) or [] - if rule_index >= len(rules): - raise UserInputError( - f"rule_index {rule_index} is out of range for sheet '{target_sheet_name}' (current count: {len(rules)})." + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute ) - new_rules_state = copy.deepcopy(rules) - del new_rules_state[rule_index] + state_text = _format_conditional_rules_section( + target_sheet_name, new_rules_state, sheet_titles, indent="" + ) - request_body = { - "requests": [ - { - "deleteConditionalFormatRule": { - "index": rule_index, - "sheetId": sheet_id, - } - } - ] - } - - await asyncio.to_thread( - service.spreadsheets() - .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) - .execute - ) - - state_text = _format_conditional_rules_section( - target_sheet_name, new_rules_state, sheet_titles, indent="" - ) - - return "\n".join( - [ - f"Deleted conditional format at index {rule_index} on sheet '{target_sheet_name}' in spreadsheet {spreadsheet_id} for {user_google_email}.", - state_text, - ] - ) + return "\n".join( + [ + f"Deleted conditional format at index {rule_index} on sheet " + f"'{target_sheet_name}' in spreadsheet {spreadsheet_id} " + f"for {user_google_email}.", + state_text, + ] + ) @server.tool() @@ -1232,7 +1228,5 @@ async def create_sheet( _comment_tools = create_comment_tools("spreadsheet", "spreadsheet_id") # Extract and register the functions -read_sheet_comments = _comment_tools["read_comments"] -create_sheet_comment = _comment_tools["create_comment"] -reply_to_sheet_comment = _comment_tools["reply_to_comment"] -resolve_sheet_comment = _comment_tools["resolve_comment"] +list_spreadsheet_comments = _comment_tools["list_comments"] +manage_spreadsheet_comment = _comment_tools["manage_comment"] diff --git a/gslides/slides_tools.py b/gslides/slides_tools.py index 2c2c9be..02a5007 100644 --- a/gslides/slides_tools.py +++ b/gslides/slides_tools.py @@ -322,13 +322,9 @@ You can view or download the thumbnail using the provided URL.""" # Create comment management tools for slides _comment_tools = create_comment_tools("presentation", "presentation_id") -read_presentation_comments = _comment_tools["read_comments"] -create_presentation_comment = _comment_tools["create_comment"] -reply_to_presentation_comment = _comment_tools["reply_to_comment"] -resolve_presentation_comment = _comment_tools["resolve_comment"] +list_presentation_comments = _comment_tools["list_comments"] +manage_presentation_comment = _comment_tools["manage_comment"] # Aliases for backwards compatibility and intuitive naming -read_slide_comments = read_presentation_comments -create_slide_comment = create_presentation_comment -reply_to_slide_comment = reply_to_presentation_comment -resolve_slide_comment = resolve_presentation_comment +list_slide_comments = list_presentation_comments +manage_slide_comment = manage_presentation_comment diff --git a/gtasks/tasks_tools.py b/gtasks/tasks_tools.py index 1e5b7ab..cc6d6bf 100644 --- a/gtasks/tasks_tools.py +++ b/gtasks/tasks_tools.py @@ -15,7 +15,7 @@ from mcp import Resource from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider from auth.service_decorator import require_google_service from core.server import server -from core.utils import handle_http_errors +from core.utils import UserInputError, handle_http_errors logger = logging.getLogger(__name__) @@ -193,138 +193,155 @@ async def get_task_list( raise Exception(message) -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("create_task_list", service_type="tasks") # type: ignore -async def create_task_list( +# --- Task list _impl functions --- + + +async def _create_task_list_impl( service: Resource, user_google_email: str, title: str ) -> str: - """ - Create a new task list. - - Args: - user_google_email (str): The user's Google email address. Required. - title (str): The title of the new task list. - - Returns: - str: Confirmation message with the new task list ID and details. - """ + """Implementation for creating a new task list.""" logger.info( f"[create_task_list] Invoked. Email: '{user_google_email}', Title: '{title}'" ) - try: - body = {"title": title} + body = {"title": title} - result = await asyncio.to_thread(service.tasklists().insert(body=body).execute) + result = await asyncio.to_thread(service.tasklists().insert(body=body).execute) - response = f"""Task List Created for {user_google_email}: + response = f"""Task List Created for {user_google_email}: - Title: {result["title"]} - ID: {result["id"]} - Created: {result.get("updated", "N/A")} - Self Link: {result.get("selfLink", "N/A")}""" - logger.info( - f"Created task list '{title}' with ID {result['id']} for {user_google_email}" - ) - return response - - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info( + f"Created task list '{title}' with ID {result['id']} for {user_google_email}" + ) + return response -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("update_task_list", service_type="tasks") # type: ignore -async def update_task_list( +async def _update_task_list_impl( service: Resource, user_google_email: str, task_list_id: str, title: str ) -> str: - """ - Update an existing task list. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list to update. - title (str): The new title for the task list. - - Returns: - str: Confirmation message with updated task list details. - """ + """Implementation for updating an existing task list.""" logger.info( f"[update_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, New Title: '{title}'" ) - try: - body = {"id": task_list_id, "title": title} + body = {"id": task_list_id, "title": title} - result = await asyncio.to_thread( - service.tasklists().update(tasklist=task_list_id, body=body).execute - ) + result = await asyncio.to_thread( + service.tasklists().update(tasklist=task_list_id, body=body).execute + ) - response = f"""Task List Updated for {user_google_email}: + response = f"""Task List Updated for {user_google_email}: - Title: {result["title"]} - ID: {result["id"]} - Updated: {result.get("updated", "N/A")}""" - logger.info( - f"Updated task list {task_list_id} with new title '{title}' for {user_google_email}" - ) - return response - - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info( + f"Updated task list {task_list_id} with new title '{title}' for {user_google_email}" + ) + return response -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("delete_task_list", service_type="tasks") # type: ignore -async def delete_task_list( +async def _delete_task_list_impl( service: Resource, user_google_email: str, task_list_id: str ) -> str: - """ - Delete a task list. Note: This will also delete all tasks in the list. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list to delete. - - Returns: - str: Confirmation message. - """ + """Implementation for deleting a task list.""" logger.info( f"[delete_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" ) - try: - await asyncio.to_thread( - service.tasklists().delete(tasklist=task_list_id).execute + await asyncio.to_thread(service.tasklists().delete(tasklist=task_list_id).execute) + + response = f"Task list {task_list_id} has been deleted for {user_google_email}. All tasks in this list have also been deleted." + + logger.info(f"Deleted task list {task_list_id} for {user_google_email}") + return response + + +async def _clear_completed_tasks_impl( + service: Resource, user_google_email: str, task_list_id: str +) -> str: + """Implementation for clearing completed tasks from a task list.""" + logger.info( + f"[clear_completed_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + ) + + await asyncio.to_thread(service.tasks().clear(tasklist=task_list_id).execute) + + response = f"All completed tasks have been cleared from task list {task_list_id} for {user_google_email}. The tasks are now hidden and won't appear in default task list views." + + logger.info( + f"Cleared completed tasks from list {task_list_id} for {user_google_email}" + ) + return response + + +# --- Consolidated manage_task_list tool --- + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks") # type: ignore +@handle_http_errors("manage_task_list", service_type="tasks") # type: ignore +async def manage_task_list( + service: Resource, + user_google_email: str, + action: str, + task_list_id: Optional[str] = None, + title: Optional[str] = None, +) -> str: + """ + Manage task lists: create, update, delete, or clear completed tasks. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform. Must be one of: "create", "update", "delete", "clear_completed". + task_list_id (Optional[str]): The ID of the task list. Required for "update", "delete", and "clear_completed" actions. + title (Optional[str]): The title for the task list. Required for "create" and "update" actions. + + Returns: + str: Result of the requested action. + """ + logger.info( + f"[manage_task_list] Invoked. Email: '{user_google_email}', Action: '{action}'" + ) + + valid_actions = ("create", "update", "delete", "clear_completed") + if action not in valid_actions: + raise UserInputError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" ) - response = f"Task list {task_list_id} has been deleted for {user_google_email}. All tasks in this list have also been deleted." + if action == "create": + if not title: + raise UserInputError("'title' is required for the 'create' action.") + return await _create_task_list_impl(service, user_google_email, title) - logger.info(f"Deleted task list {task_list_id} for {user_google_email}") - return response + if action == "update": + if not task_list_id: + raise UserInputError("'task_list_id' is required for the 'update' action.") + if not title: + raise UserInputError("'title' is required for the 'update' action.") + return await _update_task_list_impl( + service, user_google_email, task_list_id, title + ) - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + if action == "delete": + if not task_list_id: + raise UserInputError("'task_list_id' is required for the 'delete' action.") + return await _delete_task_list_impl(service, user_google_email, task_list_id) + + # action == "clear_completed" + if not task_list_id: + raise UserInputError( + "'task_list_id' is required for the 'clear_completed' action." + ) + return await _clear_completed_tasks_impl(service, user_google_email, task_list_id) + + +# --- Task tools --- @server.tool() # type: ignore @@ -633,10 +650,10 @@ async def get_task( raise Exception(message) -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("create_task", service_type="tasks") # type: ignore -async def create_task( +# --- Task _impl functions --- + + +async def _create_task_impl( service: Resource, user_google_email: str, task_list_id: str, @@ -646,72 +663,45 @@ async def create_task( parent: Optional[str] = None, previous: Optional[str] = None, ) -> str: - """ - Create a new task in a task list. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list to create the task in. - title (str): The title of the task. - notes (Optional[str]): Notes/description for the task. - due (Optional[str]): Due date in RFC 3339 format (e.g., "2024-12-31T23:59:59Z"). - parent (Optional[str]): Parent task ID (for subtasks). - previous (Optional[str]): Previous sibling task ID (for positioning). - - Returns: - str: Confirmation message with the new task ID and details. - """ + """Implementation for creating a new task in a task list.""" logger.info( f"[create_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Title: '{title}'" ) - try: - body = {"title": title} - if notes: - body["notes"] = notes - if due: - body["due"] = due + body = {"title": title} + if notes: + body["notes"] = notes + if due: + body["due"] = due - params = {"tasklist": task_list_id, "body": body} - if parent: - params["parent"] = parent - if previous: - params["previous"] = previous + params = {"tasklist": task_list_id, "body": body} + if parent: + params["parent"] = parent + if previous: + params["previous"] = previous - result = await asyncio.to_thread(service.tasks().insert(**params).execute) + result = await asyncio.to_thread(service.tasks().insert(**params).execute) - response = f"""Task Created for {user_google_email}: + response = f"""Task Created for {user_google_email}: - Title: {result["title"]} - ID: {result["id"]} - Status: {result.get("status", "N/A")} - Updated: {result.get("updated", "N/A")}""" - if result.get("due"): - response += f"\n- Due Date: {result['due']}" - if result.get("notes"): - response += f"\n- Notes: {result['notes']}" - if result.get("webViewLink"): - response += f"\n- Web View Link: {result['webViewLink']}" + if result.get("due"): + response += f"\n- Due Date: {result['due']}" + if result.get("notes"): + response += f"\n- Notes: {result['notes']}" + if result.get("webViewLink"): + response += f"\n- Web View Link: {result['webViewLink']}" - logger.info( - f"Created task '{title}' with ID {result['id']} for {user_google_email}" - ) - return response - - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info( + f"Created task '{title}' with ID {result['id']} for {user_google_email}" + ) + return response -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("update_task", service_type="tasks") # type: ignore -async def update_task( +async def _update_task_impl( service: Resource, user_google_email: str, task_list_id: str, @@ -721,126 +711,74 @@ async def update_task( status: Optional[str] = None, due: Optional[str] = None, ) -> str: - """ - Update an existing task. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list containing the task. - task_id (str): The ID of the task to update. - title (Optional[str]): New title for the task. - notes (Optional[str]): New notes/description for the task. - status (Optional[str]): New status ("needsAction" or "completed"). - due (Optional[str]): New due date in RFC 3339 format. - - Returns: - str: Confirmation message with updated task details. - """ + """Implementation for updating an existing task.""" logger.info( f"[update_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" ) - try: - # First get the current task to build the update body - current_task = await asyncio.to_thread( - service.tasks().get(tasklist=task_list_id, task=task_id).execute - ) + # First get the current task to build the update body + current_task = await asyncio.to_thread( + service.tasks().get(tasklist=task_list_id, task=task_id).execute + ) - body = { - "id": task_id, - "title": title if title is not None else current_task.get("title", ""), - "status": status - if status is not None - else current_task.get("status", "needsAction"), - } + body = { + "id": task_id, + "title": title if title is not None else current_task.get("title", ""), + "status": status + if status is not None + else current_task.get("status", "needsAction"), + } - if notes is not None: - body["notes"] = notes - elif current_task.get("notes"): - body["notes"] = current_task["notes"] + if notes is not None: + body["notes"] = notes + elif current_task.get("notes"): + body["notes"] = current_task["notes"] - if due is not None: - body["due"] = due - elif current_task.get("due"): - body["due"] = current_task["due"] + if due is not None: + body["due"] = due + elif current_task.get("due"): + body["due"] = current_task["due"] - result = await asyncio.to_thread( - service.tasks() - .update(tasklist=task_list_id, task=task_id, body=body) - .execute - ) + result = await asyncio.to_thread( + service.tasks().update(tasklist=task_list_id, task=task_id, body=body).execute + ) - response = f"""Task Updated for {user_google_email}: + response = f"""Task Updated for {user_google_email}: - Title: {result["title"]} - ID: {result["id"]} - Status: {result.get("status", "N/A")} - Updated: {result.get("updated", "N/A")}""" - if result.get("due"): - response += f"\n- Due Date: {result['due']}" - if result.get("notes"): - response += f"\n- Notes: {result['notes']}" - if result.get("completed"): - response += f"\n- Completed: {result['completed']}" + if result.get("due"): + response += f"\n- Due Date: {result['due']}" + if result.get("notes"): + response += f"\n- Notes: {result['notes']}" + if result.get("completed"): + response += f"\n- Completed: {result['completed']}" - logger.info(f"Updated task {task_id} for {user_google_email}") - return response - - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Updated task {task_id} for {user_google_email}") + return response -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("delete_task", service_type="tasks") # type: ignore -async def delete_task( +async def _delete_task_impl( service: Resource, user_google_email: str, task_list_id: str, task_id: str ) -> str: - """ - Delete a task from a task list. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list containing the task. - task_id (str): The ID of the task to delete. - - Returns: - str: Confirmation message. - """ + """Implementation for deleting a task from a task list.""" logger.info( f"[delete_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" ) - try: - await asyncio.to_thread( - service.tasks().delete(tasklist=task_list_id, task=task_id).execute - ) + await asyncio.to_thread( + service.tasks().delete(tasklist=task_list_id, task=task_id).execute + ) - response = f"Task {task_id} has been deleted from task list {task_list_id} for {user_google_email}." + response = f"Task {task_id} has been deleted from task list {task_list_id} for {user_google_email}." - logger.info(f"Deleted task {task_id} for {user_google_email}") - return response - - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + logger.info(f"Deleted task {task_id} for {user_google_email}") + return response -@server.tool() # type: ignore -@require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("move_task", service_type="tasks") # type: ignore -async def move_task( +async def _move_task_impl( service: Resource, user_google_email: str, task_list_id: str, @@ -849,105 +787,148 @@ async def move_task( previous: Optional[str] = None, destination_task_list: Optional[str] = None, ) -> str: - """ - Move a task to a different position or parent within the same list, or to a different list. - - Args: - user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the current task list containing the task. - task_id (str): The ID of the task to move. - parent (Optional[str]): New parent task ID (for making it a subtask). - previous (Optional[str]): Previous sibling task ID (for positioning). - destination_task_list (Optional[str]): Destination task list ID (for moving between lists). - - Returns: - str: Confirmation message with updated task details. - """ + """Implementation for moving a task to a different position, parent, or list.""" logger.info( f"[move_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" ) - try: - params = {"tasklist": task_list_id, "task": task_id} - if parent: - params["parent"] = parent - if previous: - params["previous"] = previous - if destination_task_list: - params["destinationTasklist"] = destination_task_list + params = {"tasklist": task_list_id, "task": task_id} + if parent: + params["parent"] = parent + if previous: + params["previous"] = previous + if destination_task_list: + params["destinationTasklist"] = destination_task_list - result = await asyncio.to_thread(service.tasks().move(**params).execute) + result = await asyncio.to_thread(service.tasks().move(**params).execute) - response = f"""Task Moved for {user_google_email}: + response = f"""Task Moved for {user_google_email}: - Title: {result["title"]} - ID: {result["id"]} - Status: {result.get("status", "N/A")} - Updated: {result.get("updated", "N/A")}""" - if result.get("parent"): - response += f"\n- Parent Task ID: {result['parent']}" - if result.get("position"): - response += f"\n- Position: {result['position']}" + if result.get("parent"): + response += f"\n- Parent Task ID: {result['parent']}" + if result.get("position"): + response += f"\n- Position: {result['position']}" - move_details = [] - if destination_task_list: - move_details.append(f"moved to task list {destination_task_list}") - if parent: - move_details.append(f"made a subtask of {parent}") - if previous: - move_details.append(f"positioned after {previous}") + move_details = [] + if destination_task_list: + move_details.append(f"moved to task list {destination_task_list}") + if parent: + move_details.append(f"made a subtask of {parent}") + if previous: + move_details.append(f"positioned after {previous}") - if move_details: - response += f"\n- Move Details: {', '.join(move_details)}" + if move_details: + response += f"\n- Move Details: {', '.join(move_details)}" - logger.info(f"Moved task {task_id} for {user_google_email}") - return response + logger.info(f"Moved task {task_id} for {user_google_email}") + return response - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + +# --- Consolidated manage_task tool --- @server.tool() # type: ignore @require_google_service("tasks", "tasks") # type: ignore -@handle_http_errors("clear_completed_tasks", service_type="tasks") # type: ignore -async def clear_completed_tasks( - service: Resource, user_google_email: str, task_list_id: str +@handle_http_errors("manage_task", service_type="tasks") # type: ignore +async def manage_task( + service: Resource, + user_google_email: str, + action: str, + task_list_id: str, + task_id: Optional[str] = None, + title: Optional[str] = None, + notes: Optional[str] = None, + status: Optional[str] = None, + due: Optional[str] = None, + parent: Optional[str] = None, + previous: Optional[str] = None, + destination_task_list: Optional[str] = None, ) -> str: """ - Clear all completed tasks from a task list. The tasks will be marked as hidden. + Manage tasks: create, update, delete, or move tasks within task lists. Args: user_google_email (str): The user's Google email address. Required. - task_list_id (str): The ID of the task list to clear completed tasks from. + action (str): The action to perform. Must be one of: "create", "update", "delete", "move". + task_list_id (str): The ID of the task list. Required for all actions. + task_id (Optional[str]): The ID of the task. Required for "update", "delete", and "move" actions. + title (Optional[str]): The title of the task. Required for "create", optional for "update". + notes (Optional[str]): Notes/description for the task. Used by "create" and "update" actions. + status (Optional[str]): Task status ("needsAction" or "completed"). Used by "update" action. + due (Optional[str]): Due date in RFC 3339 format (e.g., "2024-12-31T23:59:59Z"). Used by "create" and "update" actions. + parent (Optional[str]): Parent task ID (for subtasks). Used by "create" and "move" actions. + previous (Optional[str]): Previous sibling task ID (for positioning). Used by "create" and "move" actions. + destination_task_list (Optional[str]): Destination task list ID (for moving between lists). Used by "move" action. Returns: - str: Confirmation message. + str: Result of the requested action. """ logger.info( - f"[clear_completed_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + f"[manage_task] Invoked. Email: '{user_google_email}', Action: '{action}', Task List ID: {task_list_id}" ) - try: - await asyncio.to_thread(service.tasks().clear(tasklist=task_list_id).execute) + allowed_statuses = {"needsAction", "completed"} + if status is not None and status not in allowed_statuses: + raise UserInputError("invalid status: must be 'needsAction' or 'completed'") - response = f"All completed tasks have been cleared from task list {task_list_id} for {user_google_email}. The tasks are now hidden and won't appear in default task list views." - - logger.info( - f"Cleared completed tasks from list {task_list_id} for {user_google_email}" + valid_actions = ("create", "update", "delete", "move") + if action not in valid_actions: + raise UserInputError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" ) - return response - except HttpError as error: - message = _format_reauth_message(error, user_google_email) - logger.error(message, exc_info=True) - raise Exception(message) - except Exception as e: - message = f"Unexpected error: {e}." - logger.exception(message) - raise Exception(message) + if action == "create": + if status is not None: + raise UserInputError("'status' is only supported for the 'update' action.") + if not title: + raise UserInputError("'title' is required for the 'create' action.") + return await _create_task_impl( + service, + user_google_email, + task_list_id, + title, + notes=notes, + due=due, + parent=parent, + previous=previous, + ) + + if action == "update": + if status is not None and status not in allowed_statuses: + raise UserInputError("invalid status: must be 'needsAction' or 'completed'") + if not task_id: + raise UserInputError("'task_id' is required for the 'update' action.") + return await _update_task_impl( + service, + user_google_email, + task_list_id, + task_id, + title=title, + notes=notes, + status=status, + due=due, + ) + + if action == "delete": + if not task_id: + raise UserInputError("'task_id' is required for the 'delete' action.") + return await _delete_task_impl( + service, user_google_email, task_list_id, task_id + ) + + # action == "move" + if not task_id: + raise UserInputError("'task_id' is required for the 'move' action.") + return await _move_task_impl( + service, + user_google_email, + task_list_id, + task_id, + parent=parent, + previous=previous, + destination_task_list=destination_task_list, + ) diff --git a/manifest.json b/manifest.json index 815f9c1..a5c02dc 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "dxt_version": "0.1", "name": "workspace-mcp", "display_name": "Google Workspace MCP", - "version": "1.13.1", + "version": "1.14.0", "description": "Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Chat and Custom Search through all MCP clients, AI assistants and developer tools", "long_description": "A production-ready MCP server that integrates all major Google Workspace services with AI assistants. Includes Google PSE integration for custom web searches.", "author": { diff --git a/pyproject.toml b/pyproject.toml index 708851e..2b3b828 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "workspace-mcp" -version = "1.13.1" +version = "1.14.0" description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive" readme = "README.md" keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"] @@ -22,6 +22,7 @@ dependencies = [ "python-dotenv>=1.1.0", "pyyaml>=6.0.2", "cryptography>=45.0.0", + "defusedxml>=0.7.1", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/server.json b/server.json index e51d3d7..4e2353d 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.taylorwilsdon/workspace-mcp", "description": "Google Workspace MCP server for Gmail, Drive, Calendar, Docs, Sheets, Slides, Forms, Tasks, Chat.", "status": "active", - "version": "1.13.1", + "version": "1.14.0", "packages": [ { "registryType": "pypi", @@ -11,7 +11,7 @@ "transport": { "type": "stdio" }, - "version": "1.13.1" + "version": "1.14.0" } ] } diff --git a/tests/auth/test_google_auth_pkce.py b/tests/auth/test_google_auth_pkce.py new file mode 100644 index 0000000..57e775e --- /dev/null +++ b/tests/auth/test_google_auth_pkce.py @@ -0,0 +1,118 @@ +"""Regression tests for OAuth PKCE flow wiring.""" + +import os +import sys +from unittest.mock import patch + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from auth.google_auth import create_oauth_flow # noqa: E402 + + +DUMMY_CLIENT_CONFIG = { + "web": { + "client_id": "dummy-client-id.apps.googleusercontent.com", + "client_secret": "dummy-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } +} + + +def test_create_oauth_flow_autogenerates_verifier_when_missing(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-1", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_preserves_callback_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-2", + code_verifier="saved-verifier", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["code_verifier"] == "saved-verifier" + assert kwargs["autogenerate_code_verifier"] is False + + +def test_create_oauth_flow_file_config_still_enables_pkce(): + expected_flow = object() + with ( + patch("auth.google_auth.load_client_secrets_from_env", return_value=None), + patch("auth.google_auth.os.path.exists", return_value=True), + patch( + "auth.google_auth.Flow.from_client_secrets_file", + return_value=expected_flow, + ) as mock_from_file, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-3", + ) + + assert flow is expected_flow + _args, kwargs = mock_from_file.call_args + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-4", + autogenerate_code_verifier=False, + ) + + assert flow is expected_flow + _args, kwargs = mock_from_client_config.call_args + assert kwargs["autogenerate_code_verifier"] is False + assert "code_verifier" not in kwargs diff --git a/tests/gcontacts/test_contacts_tools.py b/tests/gcontacts/test_contacts_tools.py index b82614b..a3e9a8b 100644 --- a/tests/gcontacts/test_contacts_tools.py +++ b/tests/gcontacts/test_contacts_tools.py @@ -291,9 +291,7 @@ class TestImports: assert hasattr(contacts_tools, "list_contacts") assert hasattr(contacts_tools, "get_contact") assert hasattr(contacts_tools, "search_contacts") - assert hasattr(contacts_tools, "create_contact") - assert hasattr(contacts_tools, "update_contact") - assert hasattr(contacts_tools, "delete_contact") + assert hasattr(contacts_tools, "manage_contact") def test_import_group_tools(self): """Test that group tools can be imported.""" @@ -301,18 +299,13 @@ class TestImports: assert hasattr(contacts_tools, "list_contact_groups") assert hasattr(contacts_tools, "get_contact_group") - assert hasattr(contacts_tools, "create_contact_group") - assert hasattr(contacts_tools, "update_contact_group") - assert hasattr(contacts_tools, "delete_contact_group") - assert hasattr(contacts_tools, "modify_contact_group_members") + assert hasattr(contacts_tools, "manage_contact_group") def test_import_batch_tools(self): """Test that batch tools can be imported.""" from gcontacts import contacts_tools - assert hasattr(contacts_tools, "batch_create_contacts") - assert hasattr(contacts_tools, "batch_update_contacts") - assert hasattr(contacts_tools, "batch_delete_contacts") + assert hasattr(contacts_tools, "manage_contacts_batch") class TestConstants: diff --git a/tests/gdocs/test_docs_markdown.py b/tests/gdocs/test_docs_markdown.py index fceabfe..804c390 100644 --- a/tests/gdocs/test_docs_markdown.py +++ b/tests/gdocs/test_docs_markdown.py @@ -270,6 +270,69 @@ class TestLists: assert "- Item two" in md +CHECKLIST_DOC = { + "title": "Checklist Test", + "lists": { + "kix.checklist001": { + "listProperties": { + "nestingLevels": [ + {"glyphType": "GLYPH_TYPE_UNSPECIFIED"}, + ] + } + } + }, + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Buy groceries\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.checklist001", "nestingLevel": 0}, + } + }, + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Walk the dog\n", + "textStyle": {"strikethrough": True}, + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.checklist001", "nestingLevel": 0}, + } + }, + ] + }, +} + + +class TestChecklists: + def test_unchecked(self): + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "- [ ] Buy groceries" in md + + def test_checked(self): + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "- [x] Walk the dog" in md + + def test_checked_no_strikethrough(self): + """Checked items should not have redundant ~~strikethrough~~ markdown.""" + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "~~Walk the dog~~" not in md + + def test_regular_bullet_not_checklist(self): + """Bullet lists with glyphSymbol should remain as plain bullets.""" + md = convert_doc_to_markdown(LIST_DOC) + assert "[ ]" not in md + assert "[x]" not in md + + class TestEmptyDoc: def test_empty(self): md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}}) diff --git a/uv.lock b/uv.lock index 7842e03..859431b 100644 --- a/uv.lock +++ b/uv.lock @@ -404,6 +404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807 }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -2035,10 +2044,11 @@ wheels = [ [[package]] name = "workspace-mcp" -version = "1.13.0" +version = "1.13.1" source = { editable = "." } dependencies = [ { name = "cryptography" }, + { name = "defusedxml" }, { name = "fastapi" }, { name = "fastmcp" }, { name = "google-api-python-client" }, @@ -2098,6 +2108,7 @@ valkey = [ [package.metadata] requires-dist = [ { name = "cryptography", specifier = ">=45.0.0" }, + { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "fastmcp", specifier = ">=3.0.2" }, { name = "google-api-python-client", specifier = ">=2.168.0" },