Merge branch 'main' of https://github.com/taylorwilsdon/google_workspace_mcp into claude/fix-cors-middleware-error-sPbo6
This commit is contained in:
52
README.md
52
README.md
@@ -64,11 +64,11 @@ A production-ready MCP server that integrates all major Google Workspace service
|
||||
<td width="50%" valign="top">
|
||||
|
||||
**<span style="color:#72898f">@</span> Gmail** • **<span style="color:#72898f">≡</span> Drive** • **<span style="color:#72898f">⧖</span> Calendar** **<span style="color:#72898f">≡</span> 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 |
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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 |
|
||||
|
||||
</td>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
203
core/comments.py
203
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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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", [])
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "<br><br>" 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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
118
tests/auth/test_google_auth_pkce.py
Normal file
118
tests/auth/test_google_auth_pkce.py
Normal file
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []}})
|
||||
|
||||
13
uv.lock
generated
13
uv.lock
generated
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user