2025-05-14 09:35:48 -04:00
"""
Google Docs MCP Tools
This module provides MCP tools for interacting with Google Docs API and managing Google Docs via Drive.
"""
2025-12-13 13:49:28 -08:00
2025-05-14 09:35:48 -04:00
import logging
import asyncio
import io
2026-02-18 19:26:31 -08:00
import re
2026-03-01 10:40:19 -08:00
from typing import List , Dict , Any , Optional
2025-05-14 09:35:48 -04:00
2025-08-21 12:31:55 +02:00
from googleapiclient.http import MediaIoBaseDownload , MediaIoBaseUpload
2025-05-14 09:35:48 -04:00
# Auth & server utilities
2025-06-07 10:23:27 -04:00
from auth.service_decorator import require_google_service , require_multiple_services
2025-07-17 13:57:21 -04:00
from core.utils import extract_office_xml_text , handle_http_errors
2025-06-07 10:23:27 -04:00
from core.server import server
2025-07-01 18:56:53 -07:00
from core.comments import create_comment_tools
2025-05-14 09:35:48 -04:00
2025-08-10 14:21:01 -04:00
# Import helper functions for document operations
from gdocs.docs_helpers import (
create_insert_text_request ,
create_delete_range_request ,
create_format_text_request ,
create_find_replace_request ,
create_insert_table_request ,
create_insert_page_break_request ,
create_insert_image_request ,
2025-12-13 13:49:28 -08:00
create_bullet_list_request ,
2026-03-01 10:40:19 -08:00
create_insert_doc_tab_request ,
create_update_doc_tab_request ,
2026-03-03 17:46:50 -05:00
create_delete_doc_tab_request ,
2025-08-10 14:21:01 -04:00
)
# Import document structure and table utilities
from gdocs.docs_structure import (
parse_document_structure ,
find_tables ,
2025-12-13 13:49:28 -08:00
analyze_document_complexity ,
2025-08-10 14:21:01 -04:00
)
2025-12-13 13:49:28 -08:00
from gdocs.docs_tables import extract_table_as_data
2026-02-18 19:15:56 -08:00
from gdocs.docs_markdown import (
convert_doc_to_markdown ,
format_comments_inline ,
format_comments_appendix ,
parse_drive_comments ,
)
2025-08-10 14:21:01 -04:00
2025-08-10 15:21:10 -04:00
# Import operation managers for complex business logic
from gdocs.managers import (
TableOperationManager ,
HeaderFooterManager ,
ValidationManager ,
2025-12-13 13:49:28 -08:00
BatchOperationManager ,
2025-08-10 15:21:10 -04:00
)
2025-12-13 11:21:35 -08:00
import json
2025-08-10 15:21:10 -04:00
2025-05-14 09:35:48 -04:00
logger = logging . getLogger ( __name__ )
2025-12-13 13:49:28 -08:00
2025-05-14 09:35:48 -04:00
@server.tool ()
2025-07-28 11:49:01 -04:00
@handle_http_errors ( "search_docs" , is_read_only = True , service_type = "docs" )
2025-06-07 10:23:27 -04:00
@require_google_service ( "drive" , "drive_read" )
2025-05-14 09:35:48 -04:00
async def search_docs (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-14 09:35:48 -04:00
query : str ,
page_size : int = 10 ,
2025-06-06 17:32:09 -04:00
) -> str :
2025-05-14 09:35:48 -04:00
"""
Searches for Google Docs by name using Drive API (mimeType filter).
2025-06-07 10:23:27 -04:00
2025-06-06 17:32:09 -04:00
Returns:
str: A formatted list of Google Docs matching the search query.
2025-05-14 09:35:48 -04:00
"""
2025-06-07 10:23:27 -04:00
logger . info ( f "[search_docs] Email= { user_google_email } , Query=' { query } '" )
2025-05-14 09:35:48 -04:00
2025-06-18 16:29:35 -04:00
escaped_query = query . replace ( "'" , " \\ '" )
response = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . files ()
. list (
2025-06-18 16:29:35 -04:00
q = f "name contains ' { escaped_query } ' and mimeType='application/vnd.google-apps.document' and trashed=false" ,
pageSize = page_size ,
2025-11-04 10:19:37 -05:00
fields = "files(id, name, createdTime, modifiedTime, webViewLink)" ,
supportsAllDrives = True ,
2025-12-13 13:49:28 -08:00
includeItemsFromAllDrives = True ,
)
. execute
2025-06-18 16:29:35 -04:00
)
2025-12-13 13:49:28 -08:00
files = response . get ( "files" , [])
2025-06-18 16:29:35 -04:00
if not files :
return f "No Google Docs found matching ' { query } '."
output = [ f "Found { len ( files ) } Google Docs matching ' { query } ':" ]
for f in files :
output . append (
f "- { f [ 'name' ] } (ID: { f [ 'id' ] } ) Modified: { f . get ( 'modifiedTime' ) } Link: { f . get ( 'webViewLink' ) } "
2025-05-14 09:35:48 -04:00
)
2025-06-18 16:29:35 -04:00
return " \n " . join ( output )
2025-05-14 09:35:48 -04:00
2025-12-13 13:49:28 -08:00
2025-05-14 09:35:48 -04:00
@server.tool ()
2025-07-28 11:49:01 -04:00
@handle_http_errors ( "get_doc_content" , is_read_only = True , service_type = "docs" )
2025-12-13 13:49:28 -08:00
@require_multiple_services (
[
{
"service_type" : "drive" ,
"scopes" : "drive_read" ,
"param_name" : "drive_service" ,
},
{ "service_type" : "docs" , "scopes" : "docs_read" , "param_name" : "docs_service" },
]
)
2025-05-14 09:35:48 -04:00
async def get_doc_content (
2025-09-28 15:34:19 -04:00
drive_service : Any ,
docs_service : Any ,
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-14 09:35:48 -04:00
document_id : str ,
2025-06-06 17:32:09 -04:00
) -> str :
2025-05-14 09:35:48 -04:00
"""
2025-05-24 13:58:38 -04:00
Retrieves content of a Google Doc or a Drive file (like .docx) identified by document_id.
- Native Google Docs: Fetches content via Docs API.
- Office files (.docx, etc.) stored in Drive: Downloads via Drive API and extracts text.
2025-06-07 10:23:27 -04:00
2025-06-06 17:32:09 -04:00
Returns:
str: The document content with metadata header.
2025-05-14 09:35:48 -04:00
"""
2025-12-13 13:49:28 -08:00
logger . info (
f "[get_doc_content] Invoked. Document/File ID: ' { document_id } ' for user ' { user_google_email } '"
)
2025-05-14 09:35:48 -04:00
2025-06-18 16:29:35 -04:00
# Step 2: Get file metadata from Drive
file_metadata = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
drive_service . files ()
. get (
fileId = document_id ,
fields = "id, name, mimeType, webViewLink" ,
supportsAllDrives = True ,
)
. execute
2025-06-18 16:29:35 -04:00
)
mime_type = file_metadata . get ( "mimeType" , "" )
file_name = file_metadata . get ( "name" , "Unknown File" )
web_view_link = file_metadata . get ( "webViewLink" , "#" )
2025-12-13 13:49:28 -08:00
logger . info (
f "[get_doc_content] File ' { file_name } ' (ID: { document_id } ) has mimeType: ' { mime_type } '"
)
2025-06-18 16:29:35 -04:00
2025-12-13 13:49:28 -08:00
body_text = "" # Initialize body_text
2025-06-18 16:29:35 -04:00
# Step 3: Process based on mimeType
if mime_type == "application/vnd.google-apps.document" :
2025-07-18 18:04:06 -04:00
logger . info ( "[get_doc_content] Processing as native Google Doc." )
2025-06-18 16:29:35 -04:00
doc_data = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
docs_service . documents ()
. get ( documentId = document_id , includeTabsContent = True )
. execute
2025-05-24 13:58:38 -04:00
)
2025-07-19 19:41:25 -04:00
# Tab header format constant
2026-03-01 10:40:19 -08:00
TAB_HEADER_FORMAT = " \n --- TAB: {tab_name} (ID: {tab_id} ) --- \n "
2025-08-02 09:52:16 -04:00
2026-03-01 10:40:19 -08:00
def extract_text_from_elements ( elements , tab_name = None , tab_id = None , depth = 0 ):
2025-07-19 19:34:26 -04:00
"""Extract text from document elements (paragraphs, tables, etc.)"""
2025-07-19 19:39:04 -04:00
# Prevent infinite recursion by limiting depth
if depth > 5 :
return ""
2025-07-19 19:34:26 -04:00
text_lines = []
if tab_name :
2026-03-01 10:40:19 -08:00
text_lines . append (
TAB_HEADER_FORMAT . format ( tab_name = tab_name , tab_id = tab_id )
)
2025-07-19 19:34:26 -04:00
2025-07-19 19:33:55 -04:00
for element in elements :
2025-12-13 13:49:28 -08:00
if "paragraph" in element :
paragraph = element . get ( "paragraph" , {})
para_elements = paragraph . get ( "elements" , [])
2025-07-19 19:33:55 -04:00
current_line_text = ""
for pe in para_elements :
2025-12-13 13:49:28 -08:00
text_run = pe . get ( "textRun" , {})
if text_run and "content" in text_run :
current_line_text += text_run [ "content" ]
2025-07-19 19:33:55 -04:00
if current_line_text . strip ():
text_lines . append ( current_line_text )
2025-12-13 13:49:28 -08:00
elif "table" in element :
2025-07-19 19:33:55 -04:00
# Handle table content
2025-12-13 13:49:28 -08:00
table = element . get ( "table" , {})
table_rows = table . get ( "tableRows" , [])
2025-07-19 19:33:55 -04:00
for row in table_rows :
2025-12-13 13:49:28 -08:00
row_cells = row . get ( "tableCells" , [])
2025-07-19 19:33:55 -04:00
for cell in row_cells :
2025-12-13 13:49:28 -08:00
cell_content = cell . get ( "content" , [])
cell_text = extract_text_from_elements (
cell_content , depth = depth + 1
)
2025-07-19 19:34:26 -04:00
if cell_text . strip ():
text_lines . append ( cell_text )
return "" . join ( text_lines )
def process_tab_hierarchy ( tab , level = 0 ):
"""Process a tab and its nested child tabs recursively"""
tab_text = ""
2025-12-13 13:49:28 -08:00
if "documentTab" in tab :
props = tab . get ( "tabProperties" , {})
tab_title = props . get ( "title" , "Untitled Tab" )
tab_id = props . get ( "tabId" , "Unknown ID" )
2025-07-19 19:41:25 -04:00
# Add indentation for nested tabs to show hierarchy
if level > 0 :
2026-03-01 10:40:19 -08:00
tab_title = " " * level + f " { tab_title } "
2025-12-13 13:49:28 -08:00
tab_body = tab . get ( "documentTab" , {}) . get ( "body" , {}) . get ( "content" , [])
2026-03-01 10:40:19 -08:00
tab_text += extract_text_from_elements ( tab_body , tab_title , tab_id )
2025-07-19 19:34:26 -04:00
2025-07-19 19:33:55 -04:00
# Process child tabs (nested tabs)
2025-12-13 13:49:28 -08:00
child_tabs = tab . get ( "childTabs" , [])
2025-07-19 19:33:55 -04:00
for child_tab in child_tabs :
tab_text += process_tab_hierarchy ( child_tab , level + 1 )
2025-07-19 19:34:26 -04:00
return tab_text
processed_text_lines = []
# Process main document body
2025-12-13 13:49:28 -08:00
body_elements = doc_data . get ( "body" , {}) . get ( "content" , [])
2025-07-19 19:34:26 -04:00
main_content = extract_text_from_elements ( body_elements )
if main_content . strip ():
processed_text_lines . append ( main_content )
# Process all tabs
2025-12-13 13:49:28 -08:00
tabs = doc_data . get ( "tabs" , [])
2025-07-19 19:34:26 -04:00
for tab in tabs :
tab_content = process_tab_hierarchy ( tab )
if tab_content . strip ():
processed_text_lines . append ( tab_content )
2025-06-18 16:29:35 -04:00
body_text = "" . join ( processed_text_lines )
else :
2025-12-13 13:49:28 -08:00
logger . info (
f "[get_doc_content] Processing as Drive file (e.g., .docx, other). MimeType: { mime_type } "
)
2025-06-18 16:29:35 -04:00
export_mime_type_map = {
2025-12-13 13:49:28 -08:00
# Example: "application/vnd.google-apps.spreadsheet"z: "text/csv",
# Native GSuite types that are not Docs would go here if this function
# was intended to export them. For .docx, direct download is used.
2025-06-18 16:29:35 -04:00
}
effective_export_mime = export_mime_type_map . get ( mime_type )
request_obj = (
2025-12-13 13:49:28 -08:00
drive_service . files () . export_media (
fileId = document_id ,
mimeType = effective_export_mime ,
supportsAllDrives = True ,
)
2025-06-18 16:29:35 -04:00
if effective_export_mime
2025-12-13 13:49:28 -08:00
else drive_service . files () . get_media (
fileId = document_id , supportsAllDrives = True
)
2025-05-24 13:58:38 -04:00
)
2025-06-18 16:29:35 -04:00
fh = io . BytesIO ()
downloader = MediaIoBaseDownload ( fh , request_obj )
loop = asyncio . get_event_loop ()
done = False
while not done :
status , done = await loop . run_in_executor ( None , downloader . next_chunk )
file_content_bytes = fh . getvalue ()
office_text = extract_office_xml_text ( file_content_bytes , mime_type )
if office_text :
body_text = office_text
else :
try :
body_text = file_content_bytes . decode ( "utf-8" )
except UnicodeDecodeError :
body_text = (
f "[Binary or unsupported text encoding for mimeType ' { mime_type } ' - "
f " { len ( file_content_bytes ) } bytes]"
)
header = (
f 'File: " { file_name } " (ID: { document_id } , Type: { mime_type } ) \n '
2025-12-13 13:49:28 -08:00
f "Link: { web_view_link } \n\n --- CONTENT --- \n "
2025-06-18 16:29:35 -04:00
)
return header + body_text
2025-05-14 09:35:48 -04:00
2025-12-13 13:49:28 -08:00
2025-05-14 09:35:48 -04:00
@server.tool ()
2025-07-28 11:49:01 -04:00
@handle_http_errors ( "list_docs_in_folder" , is_read_only = True , service_type = "docs" )
2025-06-07 10:23:27 -04:00
@require_google_service ( "drive" , "drive_read" )
2025-05-14 09:35:48 -04:00
async def list_docs_in_folder (
2025-12-13 13:49:28 -08:00
service : Any , user_google_email : str , folder_id : str = "root" , page_size : int = 100
2025-06-06 17:32:09 -04:00
) -> str :
2025-05-14 09:35:48 -04:00
"""
Lists Google Docs within a specific Drive folder.
2025-06-07 10:23:27 -04:00
2025-06-06 17:32:09 -04:00
Returns:
str: A formatted list of Google Docs in the specified folder.
2025-05-14 09:35:48 -04:00
"""
2025-12-13 13:49:28 -08:00
logger . info (
f "[list_docs_in_folder] Invoked. Email: ' { user_google_email } ', Folder ID: ' { folder_id } '"
)
2025-05-14 09:35:48 -04:00
2025-06-18 16:29:35 -04:00
rsp = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . files ()
. list (
2025-06-18 16:29:35 -04:00
q = f "' { folder_id } ' in parents and mimeType='application/vnd.google-apps.document' and trashed=false" ,
pageSize = page_size ,
2025-11-04 10:19:37 -05:00
fields = "files(id, name, modifiedTime, webViewLink)" ,
supportsAllDrives = True ,
2025-12-13 13:49:28 -08:00
includeItemsFromAllDrives = True ,
)
. execute
2025-06-18 16:29:35 -04:00
)
2025-12-13 13:49:28 -08:00
items = rsp . get ( "files" , [])
2025-06-18 16:29:35 -04:00
if not items :
return f "No Google Docs found in folder ' { folder_id } '."
out = [ f "Found { len ( items ) } Docs in folder ' { folder_id } ':" ]
for f in items :
2025-12-13 13:49:28 -08:00
out . append (
f "- { f [ 'name' ] } (ID: { f [ 'id' ] } ) Modified: { f . get ( 'modifiedTime' ) } Link: { f . get ( 'webViewLink' ) } "
)
2025-06-18 16:29:35 -04:00
return " \n " . join ( out )
2025-05-14 09:35:48 -04:00
2025-12-13 13:49:28 -08:00
2025-05-14 09:35:48 -04:00
@server.tool ()
2025-07-28 11:49:01 -04:00
@handle_http_errors ( "create_doc" , service_type = "docs" )
2025-07-17 13:57:21 -04:00
@require_google_service ( "docs" , "docs_write" )
2025-05-14 09:35:48 -04:00
async def create_doc (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-06-18 16:29:35 -04:00
user_google_email : str ,
2025-05-14 09:35:48 -04:00
title : str ,
2025-12-13 13:49:28 -08:00
content : str = "" ,
2025-06-06 17:32:09 -04:00
) -> str :
2025-05-14 09:35:48 -04:00
"""
Creates a new Google Doc and optionally inserts initial content.
2025-06-07 10:23:27 -04:00
2025-06-06 17:32:09 -04:00
Returns:
str: Confirmation message with document ID and link.
2025-05-14 09:35:48 -04:00
"""
2025-06-07 10:23:27 -04:00
logger . info ( f "[create_doc] Invoked. Email: ' { user_google_email } ', Title=' { title } '" )
2025-05-14 09:35:48 -04:00
2025-12-13 13:49:28 -08:00
doc = await asyncio . to_thread (
service . documents () . create ( body = { "title" : title }) . execute
)
doc_id = doc . get ( "documentId" )
2025-06-18 16:29:35 -04:00
if content :
2025-12-13 13:49:28 -08:00
requests = [{ "insertText" : { "location" : { "index" : 1 }, "text" : content }}]
await asyncio . to_thread (
service . documents ()
. batchUpdate ( documentId = doc_id , body = { "requests" : requests })
. execute
)
2025-06-18 16:29:35 -04:00
link = f "https://docs.google.com/document/d/ { doc_id } /edit"
msg = f "Created Google Doc ' { title } ' (ID: { doc_id } ) for { user_google_email } . Link: { link } "
2025-12-13 13:49:28 -08:00
logger . info (
f "Successfully created Google Doc ' { title } ' (ID: { doc_id } ) for { user_google_email } . Link: { link } "
)
2025-06-18 16:29:35 -04:00
return msg
2025-06-23 13:18:56 +01:00
2025-08-09 17:57:34 -07:00
@server.tool ()
2025-08-10 16:22:27 -04:00
@handle_http_errors ( "modify_doc_text" , service_type = "docs" )
2025-08-09 17:57:34 -07:00
@require_google_service ( "docs" , "docs_write" )
2025-08-10 16:22:27 -04:00
async def modify_doc_text (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
start_index : int ,
end_index : int = None ,
2025-08-10 16:22:27 -04:00
text : str = None ,
bold : bool = None ,
italic : bool = None ,
underline : bool = None ,
font_size : int = None ,
font_family : str = None ,
2025-12-20 22:39:41 +01:00
text_color : str = None ,
background_color : str = None ,
2026-02-07 13:44:09 -08:00
link_url : str = None ,
2025-08-09 17:57:34 -07:00
) -> str :
"""
2025-08-10 16:22:27 -04:00
Modifies text in a Google Doc - can insert/replace text and/or apply formatting in a single operation.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
2025-08-10 16:22:27 -04:00
start_index: Start position for operation (0-based)
end_index: End position for text replacement/formatting (if not provided with text, text is inserted)
text: New text to insert or replace with (optional - can format existing text without changing it)
bold: Whether to make text bold (True/False/None to leave unchanged)
2025-08-14 10:22:20 -04:00
italic: Whether to make text italic (True/False/None to leave unchanged)
2025-08-10 16:22:27 -04:00
underline: Whether to underline text (True/False/None to leave unchanged)
font_size: Font size in points
font_family: Font family name (e.g., "Arial", "Times New Roman")
2025-12-20 22:39:41 +01:00
text_color: Foreground text color (#RRGGBB)
background_color: Background/highlight color (#RRGGBB)
2026-02-07 13:44:09 -08:00
link_url: Hyperlink URL (http/https)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
2025-08-10 16:22:27 -04:00
str: Confirmation message with operation details
2025-08-09 17:57:34 -07:00
"""
2025-11-27 11:29:25 +01:00
logger . info (
f "[modify_doc_text] Doc= { document_id } , start= { start_index } , end= { end_index } , text= { text is not None } , "
2026-02-17 14:49:49 -05:00
f "formatting= { any ( p is not None for p in [ bold , italic , underline , font_size , font_family , text_color , background_color , link_url ]) } "
2025-11-27 11:29:25 +01:00
)
2025-08-10 15:21:10 -04:00
2025-08-10 16:22:27 -04:00
# Input validation
validator = ValidationManager ()
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
is_valid , error_msg = validator . validate_document_id ( document_id )
if not is_valid :
return f "Error: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
# Validate that we have something to do
2026-02-17 14:49:59 -05:00
formatting_params = [
bold ,
italic ,
underline ,
font_size ,
font_family ,
text_color ,
background_color ,
link_url ,
]
2026-02-17 14:49:49 -05:00
if text is None and not any ( p is not None for p in formatting_params ):
2026-02-07 13:44:09 -08:00
return "Error: Must provide either 'text' to insert/replace, or formatting parameters (bold, italic, underline, font_size, font_family, text_color, background_color, link_url)."
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
# Validate text formatting params if provided
2026-02-17 14:49:49 -05:00
if any ( p is not None for p in formatting_params ):
2025-11-27 11:29:25 +01:00
is_valid , error_msg = validator . validate_text_formatting_params (
2025-12-13 13:49:28 -08:00
bold ,
italic ,
underline ,
font_size ,
font_family ,
text_color ,
background_color ,
2026-02-07 13:44:09 -08:00
link_url ,
2025-11-27 11:29:25 +01:00
)
2025-08-10 16:22:27 -04:00
if not is_valid :
return f "Error: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
# For formatting, we need end_index
if end_index is None :
return "Error: 'end_index' is required when applying formatting."
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
is_valid , error_msg = validator . validate_index_range ( start_index , end_index )
if not is_valid :
return f "Error: { error_msg } "
2025-08-10 15:21:10 -04:00
2025-08-10 16:22:27 -04:00
requests = []
operations = []
# Handle text insertion/replacement
if text is not None :
if end_index is not None and end_index > start_index :
# Text replacement
if start_index == 0 :
# Special case: Cannot delete at index 0 (first section break)
# Instead, we insert new text at index 1 and then delete the old text
requests . append ( create_insert_text_request ( 1 , text ))
adjusted_end = end_index + len ( text )
2025-12-13 13:49:28 -08:00
requests . append (
create_delete_range_request ( 1 + len ( text ), adjusted_end )
)
operations . append (
f "Replaced text from index { start_index } to { end_index } "
)
2025-08-10 16:22:27 -04:00
else :
# Normal replacement: delete old text, then insert new text
2025-12-13 13:49:28 -08:00
requests . extend (
[
create_delete_range_request ( start_index , end_index ),
create_insert_text_request ( start_index , text ),
]
)
operations . append (
f "Replaced text from index { start_index } to { end_index } "
)
2025-08-10 15:33:11 -04:00
else :
2025-08-10 16:22:27 -04:00
# Text insertion
actual_index = 1 if start_index == 0 else start_index
requests . append ( create_insert_text_request ( actual_index , text ))
operations . append ( f "Inserted text at index { start_index } " )
# Handle formatting
2026-02-17 14:49:49 -05:00
if any ( p is not None for p in formatting_params ):
2025-08-10 16:22:27 -04:00
# Adjust range for formatting based on text operations
format_start = start_index
format_end = end_index
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
if text is not None :
if end_index is not None and end_index > start_index :
# Text was replaced - format the new text
format_end = start_index + len ( text )
else :
2025-08-14 10:22:20 -04:00
# Text was inserted - format the inserted text
2025-08-10 16:22:27 -04:00
actual_index = 1 if start_index == 0 else start_index
format_start = actual_index
format_end = actual_index + len ( text )
2025-08-14 10:22:20 -04:00
2025-08-10 16:22:27 -04:00
# Handle special case for formatting at index 0
if format_start == 0 :
format_start = 1
if format_end is not None and format_end <= format_start :
format_end = format_start + 1
2025-08-14 10:22:20 -04:00
2025-11-27 11:29:25 +01:00
requests . append (
create_format_text_request (
format_start ,
format_end ,
bold ,
italic ,
underline ,
font_size ,
font_family ,
text_color ,
2025-12-13 13:49:28 -08:00
background_color ,
2026-02-07 13:44:09 -08:00
link_url ,
2025-11-27 11:29:25 +01:00
)
)
2025-08-14 10:22:20 -04:00
2026-02-17 14:49:49 -05:00
format_details = [
f " { name } = { value } "
for name , value in [
( "bold" , bold ),
( "italic" , italic ),
( "underline" , underline ),
( "font_size" , font_size ),
( "font_family" , font_family ),
( "text_color" , text_color ),
( "background_color" , background_color ),
( "link_url" , link_url ),
]
if value is not None
]
2025-08-14 10:22:20 -04:00
2025-12-13 13:49:28 -08:00
operations . append (
f "Applied formatting ( { ', ' . join ( format_details ) } ) to range { format_start } - { format_end } "
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : requests })
. execute
2025-08-09 17:57:34 -07:00
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
2025-08-10 16:22:27 -04:00
operation_summary = "; " . join ( operations )
text_info = f " Text length: { len ( text ) } characters." if text else ""
return f " { operation_summary } in document { document_id } . { text_info } Link: { link } "
2025-08-09 17:57:34 -07:00
2025-12-13 13:49:28 -08:00
2025-08-09 17:57:34 -07:00
@server.tool ()
@handle_http_errors ( "find_and_replace_doc" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def find_and_replace_doc (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
find_text : str ,
replace_text : str ,
match_case : bool = False ,
2026-03-01 10:40:19 -08:00
tab_id : Optional [ str ] = None ,
2025-08-09 17:57:34 -07:00
) -> str :
"""
Finds and replaces text throughout a Google Doc.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
find_text: Text to search for
replace_text: Text to replace with
match_case: Whether to match case exactly
2026-03-01 10:40:19 -08:00
tab_id: Optional ID of the tab to target
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
str: Confirmation message with replacement count
"""
2025-12-13 13:49:28 -08:00
logger . info (
2026-03-01 10:40:19 -08:00
f "[find_and_replace_doc] Doc= { document_id } , find=' { find_text } ', replace=' { replace_text } ', tab=' { tab_id } '"
2025-12-13 13:49:28 -08:00
)
2025-08-10 15:21:10 -04:00
2026-03-03 17:46:50 -05:00
requests = [
create_find_replace_request ( find_text , replace_text , match_case , tab_id )
]
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
result = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : requests })
. execute
2025-08-09 17:57:34 -07:00
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
# Extract number of replacements from response
replacements = 0
2025-12-13 13:49:28 -08:00
if "replies" in result and result [ "replies" ]:
reply = result [ "replies" ][ 0 ]
if "replaceAllText" in reply :
replacements = reply [ "replaceAllText" ] . get ( "occurrencesChanged" , 0 )
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Replaced { replacements } occurrence(s) of ' { find_text } ' with ' { replace_text } ' in document { document_id } . Link: { link } "
@server.tool ()
@handle_http_errors ( "insert_doc_elements" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def insert_doc_elements (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
element_type : str ,
index : int ,
rows : int = None ,
columns : int = None ,
list_type : str = None ,
text : str = None ,
) -> str :
"""
Inserts structural elements like tables, lists, or page breaks into a Google Doc.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
element_type: Type of element to insert ("table", "list", "page_break")
index: Position to insert element (0-based)
rows: Number of rows for table (required for table)
columns: Number of columns for table (required for table)
list_type: Type of list ("UNORDERED", "ORDERED") (required for list)
text: Initial text content for list items
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
str: Confirmation message with insertion details
"""
2025-12-13 13:49:28 -08:00
logger . info (
f "[insert_doc_elements] Doc= { document_id } , type= { element_type } , index= { index } "
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:33:11 -04:00
# Handle the special case where we can't insert at the first section break
# If index is 0, bump it to 1 to avoid the section break
if index == 0 :
2025-08-12 09:37:20 -04:00
logger . debug ( "Adjusting index from 0 to 1 to avoid first section break" )
2025-08-10 15:33:11 -04:00
index = 1
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
requests = []
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
if element_type == "table" :
if not rows or not columns :
return "Error: 'rows' and 'columns' parameters are required for table insertion."
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
requests . append ( create_insert_table_request ( index , rows , columns ))
2025-08-09 17:57:34 -07:00
description = f "table ( { rows } x { columns } )"
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
elif element_type == "list" :
if not list_type :
return "Error: 'list_type' parameter is required for list insertion ('UNORDERED' or 'ORDERED')."
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
if not text :
text = "List item"
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
# Insert text first, then create list
2025-12-13 13:49:28 -08:00
requests . extend (
[
create_insert_text_request ( index , text + " \n " ),
create_bullet_list_request ( index , index + len ( text ), list_type ),
]
)
2025-08-09 17:57:34 -07:00
description = f " { list_type . lower () } list"
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
elif element_type == "page_break" :
2025-08-10 14:21:01 -04:00
requests . append ( create_insert_page_break_request ( index ))
2025-08-09 17:57:34 -07:00
description = "page break"
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
else :
return f "Error: Unsupported element type ' { element_type } '. Supported types: 'table', 'list', 'page_break'."
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : requests })
. execute
2025-08-09 17:57:34 -07:00
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Inserted { description } at index { index } in document { document_id } . Link: { link } "
2025-12-13 13:49:28 -08:00
2025-08-09 17:57:34 -07:00
@server.tool ()
@handle_http_errors ( "insert_doc_image" , service_type = "docs" )
2025-12-13 13:49:28 -08:00
@require_multiple_services (
[
{ "service_type" : "docs" , "scopes" : "docs_write" , "param_name" : "docs_service" },
{
"service_type" : "drive" ,
"scopes" : "drive_read" ,
"param_name" : "drive_service" ,
},
]
)
2025-08-09 17:57:34 -07:00
async def insert_doc_image (
2025-09-28 15:34:19 -04:00
docs_service : Any ,
drive_service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
image_source : str ,
index : int ,
2025-09-28 15:38:39 -04:00
width : int = 0 ,
height : int = 0 ,
2025-08-09 17:57:34 -07:00
) -> str :
"""
Inserts an image into a Google Doc from Drive or a URL.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
image_source: Drive file ID or public image URL
index: Position to insert image (0-based)
width: Image width in points (optional)
height: Image height in points (optional)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
str: Confirmation message with insertion details
"""
2025-12-13 13:49:28 -08:00
logger . info (
f "[insert_doc_image] Doc= { document_id } , source= { image_source } , index= { index } "
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:33:11 -04:00
# Handle the special case where we can't insert at the first section break
# If index is 0, bump it to 1 to avoid the section break
if index == 0 :
2025-08-12 09:37:20 -04:00
logger . debug ( "Adjusting index from 0 to 1 to avoid first section break" )
2025-08-10 15:33:11 -04:00
index = 1
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
# Determine if source is a Drive file ID or URL
2025-12-13 13:49:28 -08:00
is_drive_file = not (
image_source . startswith ( "http://" ) or image_source . startswith ( "https://" )
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
if is_drive_file :
# Verify Drive file exists and get metadata
try :
file_metadata = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
drive_service . files ()
. get (
2025-08-10 15:21:10 -04:00
fileId = image_source ,
2025-11-04 10:19:37 -05:00
fields = "id, name, mimeType" ,
2025-12-13 13:49:28 -08:00
supportsAllDrives = True ,
)
. execute
2025-08-09 17:57:34 -07:00
)
2025-12-13 13:49:28 -08:00
mime_type = file_metadata . get ( "mimeType" , "" )
if not mime_type . startswith ( "image/" ):
2025-08-09 17:57:34 -07:00
return f "Error: File { image_source } is not an image (MIME type: { mime_type } )."
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
image_uri = f "https://drive.google.com/uc?id= { image_source } "
source_description = f "Drive file { file_metadata . get ( 'name' , image_source ) } "
except Exception as e :
return f "Error: Could not access Drive file { image_source } : { str ( e ) } "
else :
image_uri = image_source
source_description = "URL image"
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Use helper to create image request
requests = [ create_insert_image_request ( index , image_uri , width , height )]
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
docs_service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : requests })
. execute
2025-08-09 17:57:34 -07:00
)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
size_info = ""
if width or height :
size_info = f " (size: { width or 'auto' } x { height or 'auto' } points)"
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Inserted { source_description }{ size_info } at index { index } in document { document_id } . Link: { link } "
2025-12-13 13:49:28 -08:00
2025-08-09 17:57:34 -07:00
@server.tool ()
@handle_http_errors ( "update_doc_headers_footers" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def update_doc_headers_footers (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
section_type : str ,
content : str ,
header_footer_type : str = "DEFAULT" ,
) -> str :
"""
Updates headers or footers in a Google Doc.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
section_type: Type of section to update ("header" or "footer")
content: Text content for the header/footer
header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE")
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
str: Confirmation message with update details
"""
logger . info ( f "[update_doc_headers_footers] Doc= { document_id } , type= { section_type } " )
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
# Input validation
validator = ValidationManager ()
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_document_id ( document_id )
if not is_valid :
return f "Error: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-12-13 13:49:28 -08:00
is_valid , error_msg = validator . validate_header_footer_params (
section_type , header_footer_type
)
2025-08-10 15:21:10 -04:00
if not is_valid :
return f "Error: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_text_content ( content )
if not is_valid :
return f "Error: { error_msg } "
# Use HeaderFooterManager to handle the complex logic
header_footer_manager = HeaderFooterManager ( service )
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
success , message = await header_footer_manager . update_header_footer_content (
document_id , section_type , content , header_footer_type
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
if success :
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f " { message } . Link: { link } "
2025-08-09 17:57:34 -07:00
else :
2025-08-10 15:21:10 -04:00
return f "Error: { message } "
2025-08-09 17:57:34 -07:00
2025-12-13 13:49:28 -08:00
2025-08-09 17:57:34 -07:00
@server.tool ()
@handle_http_errors ( "batch_update_doc" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def batch_update_doc (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-09 17:57:34 -07:00
user_google_email : str ,
document_id : str ,
2025-09-28 15:34:19 -04:00
operations : List [ Dict [ str , Any ]],
2025-08-09 17:57:34 -07:00
) -> str :
"""
Executes multiple document operations in a single atomic batch update.
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
2026-03-01 10:40:19 -08:00
operations: List of operation dicts. Each operation MUST have a 'type' field.
All operations accept an optional 'tab_id' to target a specific tab.
Supported operation types and their parameters:
insert_text - required: index (int), text (str)
delete_text - required: start_index (int), end_index (int)
replace_text - required: start_index (int), end_index (int), text (str)
format_text - required: start_index (int), end_index (int)
optional: bold, italic, underline, font_size, font_family,
text_color, background_color, link_url
update_paragraph_style
- required: start_index (int), end_index (int)
optional: heading_level (0-6, 0=normal), alignment
(START/CENTER/END/JUSTIFIED), line_spacing,
indent_first_line, indent_start, indent_end,
space_above, space_below
insert_table - required: index (int), rows (int), columns (int)
insert_page_break- required: index (int)
find_replace - required: find_text (str), replace_text (str)
optional: match_case (bool, default false)
2026-03-12 21:06:34 +01:00
create_bullet_list - required: start_index (int), end_index (int)
optional: list_type ('UNORDERED'|'ORDERED'|'NONE', default UNORDERED),
nesting_level (0-8), paragraph_start_indices (list[int])
Use list_type='NONE' to remove existing bullet/list formatting
2026-03-01 10:40:19 -08:00
insert_doc_tab - required: title (str), index (int)
optional: parent_tab_id (str)
delete_doc_tab - required: tab_id (str)
update_doc_tab - required: tab_id (str), title (str)
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Example operations:
[
{"type": "insert_text", "index": 1, "text": "Hello World"},
{"type": "format_text", "start_index": 1, "end_index": 12, "bold": true},
2026-03-01 10:40:19 -08:00
{"type": "update_paragraph_style", "start_index": 1, "end_index": 12,
"heading_level": 1, "alignment": "CENTER"},
{"type": "find_replace", "find_text": "foo", "replace_text": "bar"},
{"type": "insert_table", "index": 20, "rows": 2, "columns": 3},
{"type": "insert_doc_tab", "title": "Appendix", "index": 1}
2025-08-09 17:57:34 -07:00
]
2025-08-10 15:21:10 -04:00
2025-08-09 17:57:34 -07:00
Returns:
str: Confirmation message with batch operation results
"""
2025-08-10 15:33:11 -04:00
logger . debug ( f "[batch_update_doc] Doc= { document_id } , operations= { len ( operations ) } " )
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
# Input validation
validator = ValidationManager ()
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_document_id ( document_id )
if not is_valid :
return f "Error: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_batch_operations ( operations )
if not is_valid :
return f "Error: { error_msg } "
# Use BatchOperationManager to handle the complex logic
batch_manager = BatchOperationManager ( service )
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
success , message , metadata = await batch_manager . execute_batch_operations (
document_id , operations
2025-08-09 17:57:34 -07:00
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
if success :
link = f "https://docs.google.com/document/d/ { document_id } /edit"
2025-12-13 13:49:28 -08:00
replies_count = metadata . get ( "replies_count" , 0 )
2025-08-10 15:21:10 -04:00
return f " { message } on document { document_id } . API replies: { replies_count } . Link: { link } "
else :
return f "Error: { message } "
2025-08-09 17:57:34 -07:00
2025-12-13 13:49:28 -08:00
2025-08-10 14:21:01 -04:00
@server.tool ()
@handle_http_errors ( "inspect_doc_structure" , is_read_only = True , service_type = "docs" )
@require_google_service ( "docs" , "docs_read" )
async def inspect_doc_structure (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-10 14:21:01 -04:00
user_google_email : str ,
document_id : str ,
detailed : bool = False ,
2026-03-01 10:40:19 -08:00
tab_id : str = None ,
2025-08-10 14:21:01 -04:00
) -> str :
"""
Essential tool for finding safe insertion points and understanding document structure.
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
USE THIS FOR:
2025-08-10 14:21:01 -04:00
- Finding the correct index for table insertion
- Understanding document layout before making changes
- Locating existing tables and their positions
- Getting document statistics and complexity info
2026-03-01 10:40:19 -08:00
- Inspecting structure of specific tabs
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
CRITICAL FOR TABLE OPERATIONS:
2025-08-10 14:21:01 -04:00
ALWAYS call this BEFORE creating tables to get a safe insertion index.
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
WHAT THE OUTPUT SHOWS:
2025-08-10 14:21:01 -04:00
- total_elements: Number of document elements
- total_length: Maximum safe index for insertion
- tables: Number of existing tables
- table_details: Position and dimensions of each table
2026-03-01 10:40:19 -08:00
- tabs: List of available tabs in the document (if no tab_id specified)
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
WORKFLOW:
2025-08-10 14:21:01 -04:00
Step 1: Call this function
Step 2: Note the "total_length" value
Step 3: Use an index < total_length for table insertion
Step 4: Create your table
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to inspect
detailed: Whether to return detailed structure information
2026-03-01 10:40:19 -08:00
tab_id: Optional ID of the tab to inspect. If not provided, inspects main document.
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Returns:
str: JSON string containing document structure and safe insertion indices
"""
2026-03-01 10:40:19 -08:00
logger . debug (
f "[inspect_doc_structure] Doc= { document_id } , detailed= { detailed } , tab_id= { tab_id } "
)
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Get the document
doc = await asyncio . to_thread (
2026-03-01 10:40:19 -08:00
service . documents () . get ( documentId = document_id , includeTabsContent = True ) . execute
2025-08-10 14:21:01 -04:00
)
2025-08-10 15:21:10 -04:00
2026-03-01 10:40:19 -08:00
# If tab_id is specified, find the tab and use its content
target_content = doc . get ( "body" , {})
def find_tab ( tabs , target_id ):
for tab in tabs :
if tab . get ( "tabProperties" , {}) . get ( "tabId" ) == target_id :
return tab
if "childTabs" in tab :
found = find_tab ( tab [ "childTabs" ], target_id )
if found :
return found
return None
if tab_id :
tab = find_tab ( doc . get ( "tabs" , []), tab_id )
if tab and "documentTab" in tab :
target_content = tab [ "documentTab" ] . get ( "body" , {})
elif tab :
return f "Error: Tab { tab_id } is not a document tab and has no body content."
else :
return f "Error: Tab { tab_id } not found in document."
# Create a dummy doc object for analysis tools that expect a full doc
analysis_doc = doc . copy ()
analysis_doc [ "body" ] = target_content
2025-08-10 14:21:01 -04:00
if detailed :
# Return full parsed structure
2026-03-01 10:40:19 -08:00
structure = parse_document_structure ( analysis_doc )
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Simplify for JSON serialization
result = {
2025-12-13 13:49:28 -08:00
"title" : structure [ "title" ],
"total_length" : structure [ "total_length" ],
"statistics" : {
"elements" : len ( structure [ "body" ]),
"tables" : len ( structure [ "tables" ]),
"paragraphs" : sum (
1 for e in structure [ "body" ] if e . get ( "type" ) == "paragraph"
),
"has_headers" : bool ( structure [ "headers" ]),
"has_footers" : bool ( structure [ "footers" ]),
2025-08-10 14:21:01 -04:00
},
2025-12-13 13:49:28 -08:00
"elements" : [],
2025-08-10 14:21:01 -04:00
}
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Add element summaries
2025-12-13 13:49:28 -08:00
for element in structure [ "body" ]:
2025-08-10 14:21:01 -04:00
elem_summary = {
2025-12-13 13:49:28 -08:00
"type" : element [ "type" ],
"start_index" : element [ "start_index" ],
"end_index" : element [ "end_index" ],
2025-08-10 14:21:01 -04:00
}
2025-08-10 15:21:10 -04:00
2025-12-13 13:49:28 -08:00
if element [ "type" ] == "table" :
elem_summary [ "rows" ] = element [ "rows" ]
elem_summary [ "columns" ] = element [ "columns" ]
elem_summary [ "cell_count" ] = len ( element . get ( "cells" , []))
elif element [ "type" ] == "paragraph" :
elem_summary [ "text_preview" ] = element . get ( "text" , "" )[: 100 ]
2025-08-10 15:21:10 -04:00
2025-12-13 13:49:28 -08:00
result [ "elements" ] . append ( elem_summary )
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Add table details
2025-12-13 13:49:28 -08:00
if structure [ "tables" ]:
result [ "tables" ] = []
for i , table in enumerate ( structure [ "tables" ]):
2025-08-10 14:21:01 -04:00
table_data = extract_table_as_data ( table )
2025-12-13 13:49:28 -08:00
result [ "tables" ] . append (
{
"index" : i ,
"position" : {
"start" : table [ "start_index" ],
"end" : table [ "end_index" ],
},
"dimensions" : {
"rows" : table [ "rows" ],
"columns" : table [ "columns" ],
},
"preview" : table_data [: 3 ] if table_data else [], # First 3 rows
}
)
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
else :
# Return basic analysis
2026-03-01 10:40:19 -08:00
result = analyze_document_complexity ( analysis_doc )
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Add table information
2026-03-01 10:40:19 -08:00
tables = find_tables ( analysis_doc )
2025-08-10 14:21:01 -04:00
if tables :
2025-12-13 13:49:28 -08:00
result [ "table_details" ] = []
2025-08-10 14:21:01 -04:00
for i , table in enumerate ( tables ):
2025-12-13 13:49:28 -08:00
result [ "table_details" ] . append (
{
"index" : i ,
"rows" : table [ "rows" ],
"columns" : table [ "columns" ],
"start_index" : table [ "start_index" ],
"end_index" : table [ "end_index" ],
}
)
2025-08-10 15:21:10 -04:00
2026-03-01 10:40:19 -08:00
# Always include available tabs if no tab_id was specified
if not tab_id :
2026-03-03 17:46:50 -05:00
2026-03-01 10:40:19 -08:00
def get_tabs_summary ( tabs ):
summary = []
for tab in tabs :
props = tab . get ( "tabProperties" , {})
tab_info = {
"title" : props . get ( "title" ),
"tab_id" : props . get ( "tabId" ),
}
if "childTabs" in tab :
tab_info [ "child_tabs" ] = get_tabs_summary ( tab [ "childTabs" ])
summary . append ( tab_info )
return summary
result [ "tabs" ] = get_tabs_summary ( doc . get ( "tabs" , []))
if tab_id :
result [ "inspected_tab_id" ] = tab_id
2025-08-10 14:21:01 -04:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Document structure analysis for { document_id } : \n\n { json . dumps ( result , indent = 2 ) } \n\n Link: { link } "
2025-12-13 13:49:28 -08:00
2025-08-10 14:21:01 -04:00
@server.tool ()
@handle_http_errors ( "create_table_with_data" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def create_table_with_data (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-10 14:21:01 -04:00
user_google_email : str ,
document_id : str ,
2025-09-28 15:34:19 -04:00
table_data : List [ List [ str ]],
2025-08-10 14:21:01 -04:00
index : int ,
bold_headers : bool = True ,
2026-03-03 17:46:50 -05:00
tab_id : Optional [ str ] = None ,
2025-08-10 14:21:01 -04:00
) -> str :
"""
Creates a table and populates it with data in one reliable operation.
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
CRITICAL: YOU MUST CALL inspect_doc_structure FIRST TO GET THE INDEX!
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
MANDATORY WORKFLOW - DO THESE STEPS IN ORDER:
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Step 1: ALWAYS call inspect_doc_structure first
Step 2: Use the 'total_length' value from inspect_doc_structure as your index
Step 3: Format data as 2D list: [["col1", "col2"], ["row1col1", "row1col2"]]
Step 4: Call this function with the correct index and data
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
EXAMPLE DATA FORMAT:
table_data = [
["Header1", "Header2", "Header3"], # Row 0 - headers
2025-08-10 15:21:10 -04:00
["Data1", "Data2", "Data3"], # Row 1 - first data row
2025-08-10 14:21:01 -04:00
["Data4", "Data5", "Data6"] # Row 2 - second data row
]
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
CRITICAL INDEX REQUIREMENTS:
- NEVER use index values like 1, 2, 10 without calling inspect_doc_structure first
- ALWAYS get index from inspect_doc_structure 'total_length' field
- Index must be a valid insertion point in the document
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
DATA FORMAT REQUIREMENTS:
- Must be 2D list of strings only
- Each inner list = one table row
- All rows MUST have same number of columns
- Use empty strings "" for empty cells, never None
- Use debug_table_structure after creation to verify results
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Args:
user_google_email: User's Google email address
2025-08-10 15:21:10 -04:00
document_id: ID of the document to update
2025-08-10 14:21:01 -04:00
table_data: 2D list of strings - EXACT format: [["col1", "col2"], ["row1col1", "row1col2"]]
index: Document position (MANDATORY: get from inspect_doc_structure 'total_length')
bold_headers: Whether to make first row bold (default: true)
2026-03-03 17:46:50 -05:00
tab_id: Optional tab ID to create the table in a specific tab
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Returns:
str: Confirmation with table details and link
"""
2025-08-10 15:33:11 -04:00
logger . debug ( f "[create_table_with_data] Doc= { document_id } , index= { index } " )
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
# Input validation
validator = ValidationManager ()
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_document_id ( document_id )
2025-08-10 14:21:01 -04:00
if not is_valid :
2025-08-10 15:21:10 -04:00
return f "ERROR: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
is_valid , error_msg = validator . validate_table_data ( table_data )
if not is_valid :
return f "ERROR: { error_msg } "
2025-08-14 10:22:20 -04:00
2025-08-10 15:56:18 -04:00
is_valid , error_msg = validator . validate_index ( index , "Index" )
if not is_valid :
return f "ERROR: { error_msg } "
2025-08-10 14:21:01 -04:00
2025-08-10 15:21:10 -04:00
# Use TableOperationManager to handle the complex logic
table_manager = TableOperationManager ( service )
2025-08-14 10:22:20 -04:00
2025-08-10 15:33:11 -04:00
# Try to create the table, and if it fails due to index being at document end, retry with index-1
2025-08-10 15:21:10 -04:00
success , message , metadata = await table_manager . create_and_populate_table (
2026-03-03 17:46:50 -05:00
document_id , table_data , index , bold_headers , tab_id
2025-08-10 14:21:01 -04:00
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:33:11 -04:00
# If it failed due to index being at or beyond document end, retry with adjusted index
if not success and "must be less than the end index" in message :
2025-12-13 13:49:28 -08:00
logger . debug (
f "Index { index } is at document boundary, retrying with index { index - 1 } "
)
2025-08-10 15:33:11 -04:00
success , message , metadata = await table_manager . create_and_populate_table (
2026-03-03 17:46:50 -05:00
document_id , table_data , index - 1 , bold_headers , tab_id
2025-08-10 15:33:11 -04:00
)
2025-08-14 10:22:20 -04:00
2025-08-10 15:21:10 -04:00
if success :
link = f "https://docs.google.com/document/d/ { document_id } /edit"
2025-12-13 13:49:28 -08:00
rows = metadata . get ( "rows" , 0 )
columns = metadata . get ( "columns" , 0 )
2025-08-14 10:22:20 -04:00
2025-12-13 13:49:28 -08:00
return (
f "SUCCESS: { message } . Table: { rows } x { columns } , Index: { index } . Link: { link } "
)
2025-08-10 15:21:10 -04:00
else :
return f "ERROR: { message } "
2025-08-10 14:21:01 -04:00
@server.tool ()
@handle_http_errors ( "debug_table_structure" , is_read_only = True , service_type = "docs" )
@require_google_service ( "docs" , "docs_read" )
async def debug_table_structure (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-10 14:21:01 -04:00
user_google_email : str ,
document_id : str ,
table_index : int = 0 ,
) -> str :
"""
ESSENTIAL DEBUGGING TOOL - Use this whenever tables don't work as expected.
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
USE THIS IMMEDIATELY WHEN:
2025-08-10 14:21:01 -04:00
- Table population put data in wrong cells
2025-08-10 15:21:10 -04:00
- You get "table not found" errors
2025-08-10 14:21:01 -04:00
- Data appears concatenated in first cell
- Need to understand existing table structure
- Planning to use populate_existing_table
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
WHAT THIS SHOWS YOU:
2025-08-10 14:21:01 -04:00
- Exact table dimensions (rows × columns)
- Each cell's position coordinates (row,col)
- Current content in each cell
- Insertion indices for each cell
- Table boundaries and ranges
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
HOW TO READ THE OUTPUT:
2025-08-10 14:21:01 -04:00
- "dimensions": "2x3" = 2 rows, 3 columns
- "position": "(0,0)" = first row, first column
- "current_content": What's actually in each cell right now
- "insertion_index": Where new text would be inserted in that cell
2025-08-10 15:21:10 -04:00
2025-08-10 14:35:56 -04:00
WORKFLOW INTEGRATION:
2025-08-10 14:21:01 -04:00
1. After creating table → Use this to verify structure
2. Before populating → Use this to plan your data format
3. After population fails → Use this to see what went wrong
4. When debugging → Compare your data array to actual table structure
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Args:
user_google_email: User's Google email address
document_id: ID of the document to inspect
table_index: Which table to debug (0 = first table, 1 = second table, etc.)
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
Returns:
str: Detailed JSON structure showing table layout, cell positions, and current content
"""
2025-12-13 13:49:28 -08:00
logger . debug (
f "[debug_table_structure] Doc= { document_id } , table_index= { table_index } "
)
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Get the document
doc = await asyncio . to_thread (
service . documents () . get ( documentId = document_id ) . execute
)
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Find tables
tables = find_tables ( doc )
if table_index >= len ( tables ):
return f "Error: Table index { table_index } not found. Document has { len ( tables ) } table(s)."
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
table_info = tables [ table_index ]
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
# Extract detailed cell information
debug_info = {
2025-12-13 13:49:28 -08:00
"table_index" : table_index ,
"dimensions" : f " { table_info [ 'rows' ] } x { table_info [ 'columns' ] } " ,
"table_range" : f "[ { table_info [ 'start_index' ] } - { table_info [ 'end_index' ] } ]" ,
"cells" : [],
2025-08-10 14:21:01 -04:00
}
2025-08-10 15:21:10 -04:00
2025-12-13 13:49:28 -08:00
for row_idx , row in enumerate ( table_info [ "cells" ]):
2025-08-10 14:21:01 -04:00
row_info = []
for col_idx , cell in enumerate ( row ):
cell_debug = {
2025-12-13 13:49:28 -08:00
"position" : f "( { row_idx } , { col_idx } )" ,
"range" : f "[ { cell [ 'start_index' ] } - { cell [ 'end_index' ] } ]" ,
"insertion_index" : cell . get ( "insertion_index" , "N/A" ),
"current_content" : repr ( cell . get ( "content" , "" )),
"content_elements_count" : len ( cell . get ( "content_elements" , [])),
2025-08-10 14:21:01 -04:00
}
row_info . append ( cell_debug )
2025-12-13 13:49:28 -08:00
debug_info [ "cells" ] . append ( row_info )
2025-08-10 15:21:10 -04:00
2025-08-10 14:21:01 -04:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Table structure debug for table { table_index } : \n\n { json . dumps ( debug_info , indent = 2 ) } \n\n Link: { link } "
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
@server.tool ()
@handle_http_errors ( "export_doc_to_pdf" , service_type = "drive" )
@require_google_service ( "drive" , "drive_file" )
async def export_doc_to_pdf (
2025-09-28 15:34:19 -04:00
service : Any ,
2025-08-21 12:31:55 +02:00
user_google_email : str ,
document_id : str ,
pdf_filename : str = None ,
folder_id : str = None ,
) -> str :
"""
Exports a Google Doc to PDF format and saves it to Google Drive.
Args:
user_google_email: User's Google email address
document_id: ID of the Google Doc to export
pdf_filename: Name for the PDF file (optional - if not provided, uses original name + "_PDF")
folder_id: Drive folder ID to save PDF in (optional - if not provided, saves in root)
Returns:
str: Confirmation message with PDF file details and links
"""
2025-12-13 13:49:28 -08:00
logger . info (
f "[export_doc_to_pdf] Email= { user_google_email } , Doc= { document_id } , pdf_filename= { pdf_filename } , folder_id= { folder_id } "
)
2025-08-21 12:31:55 +02:00
# Get file metadata first to validate it's a Google Doc
try :
file_metadata = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . files ()
. get (
fileId = document_id ,
2025-11-04 10:19:37 -05:00
fields = "id, name, mimeType, webViewLink" ,
2025-12-13 13:49:28 -08:00
supportsAllDrives = True ,
)
. execute
2025-08-21 12:31:55 +02:00
)
except Exception as e :
return f "Error: Could not access document { document_id } : { str ( e ) } "
mime_type = file_metadata . get ( "mimeType" , "" )
original_name = file_metadata . get ( "name" , "Unknown Document" )
web_view_link = file_metadata . get ( "webViewLink" , "#" )
# Verify it's a Google Doc
if mime_type != "application/vnd.google-apps.document" :
return f "Error: File ' { original_name } ' is not a Google Doc (MIME type: { mime_type } ). Only native Google Docs can be exported to PDF."
logger . info ( f "[export_doc_to_pdf] Exporting ' { original_name } ' to PDF" )
# Export the document as PDF
try :
request_obj = service . files () . export_media (
2025-12-26 21:19:32 +09:00
fileId = document_id , mimeType = "application/pdf"
2025-08-21 12:31:55 +02:00
)
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
fh = io . BytesIO ()
downloader = MediaIoBaseDownload ( fh , request_obj )
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
done = False
while not done :
_ , done = await asyncio . to_thread ( downloader . next_chunk )
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
pdf_content = fh . getvalue ()
pdf_size = len ( pdf_content )
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
except Exception as e :
return f "Error: Failed to export document to PDF: { str ( e ) } "
# Determine PDF filename
if not pdf_filename :
pdf_filename = f " { original_name } _PDF.pdf"
2025-12-13 13:49:28 -08:00
elif not pdf_filename . endswith ( ".pdf" ):
pdf_filename += ".pdf"
2025-08-21 12:31:55 +02:00
# Upload PDF to Drive
try :
2025-08-23 10:16:32 -04:00
# Reuse the existing BytesIO object by resetting to the beginning
fh . seek ( 0 )
2025-08-21 12:31:55 +02:00
# Create media upload object
2025-12-13 13:49:28 -08:00
media = MediaIoBaseUpload ( fh , mimetype = "application/pdf" , resumable = True )
2025-08-21 12:31:55 +02:00
# Prepare file metadata for upload
2025-12-13 13:49:28 -08:00
file_metadata = { "name" : pdf_filename , "mimeType" : "application/pdf" }
2025-08-21 12:31:55 +02:00
# Add parent folder if specified
if folder_id :
2025-12-13 13:49:28 -08:00
file_metadata [ "parents" ] = [ folder_id ]
2025-08-21 12:31:55 +02:00
# Upload the file
uploaded_file = await asyncio . to_thread (
2025-12-13 13:49:28 -08:00
service . files ()
. create (
2025-08-21 12:31:55 +02:00
body = file_metadata ,
media_body = media ,
2025-12-13 13:49:28 -08:00
fields = "id, name, webViewLink, parents" ,
supportsAllDrives = True ,
)
. execute
)
pdf_file_id = uploaded_file . get ( "id" )
pdf_web_link = uploaded_file . get ( "webViewLink" , "#" )
pdf_parents = uploaded_file . get ( "parents" , [])
logger . info (
f "[export_doc_to_pdf] Successfully uploaded PDF to Drive: { pdf_file_id } "
2025-08-21 12:31:55 +02:00
)
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
folder_info = ""
if folder_id :
folder_info = f " in folder { folder_id } "
elif pdf_parents :
folder_info = f " in folder { pdf_parents [ 0 ] } "
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
return f "Successfully exported ' { original_name } ' to PDF and saved to Drive as ' { pdf_filename } ' (ID: { pdf_file_id } , { pdf_size : , } bytes) { folder_info } . PDF: { pdf_web_link } | Original: { web_view_link } "
2025-12-13 13:49:28 -08:00
2025-08-21 12:31:55 +02:00
except Exception as e :
return f "Error: Failed to upload PDF to Drive: { str ( e ) } . PDF was generated successfully ( { pdf_size : , } bytes) but could not be saved to Drive."
2025-08-09 17:57:34 -07:00
2025-12-25 20:11:38 +01:00
# ==============================================================================
2026-02-01 14:47:32 -05:00
# STYLING TOOLS - Paragraph Formatting
2025-12-25 20:11:38 +01:00
# ==============================================================================
2026-02-08 18:15:36 -05:00
async def _get_paragraph_start_indices_in_range (
service : Any , document_id : str , start_index : int , end_index : int
) -> list [ int ]:
"""
Fetch paragraph start indices that overlap a target range.
"""
doc_data = await asyncio . to_thread (
service . documents ()
. get (
documentId = document_id ,
2026-02-08 19:00:52 -05:00
fields = "body/content(startIndex,endIndex,paragraph)" ,
2026-02-08 18:15:36 -05:00
)
. execute
)
paragraph_starts = []
for element in doc_data . get ( "body" , {}) . get ( "content" , []):
if "paragraph" not in element :
continue
paragraph_start = element . get ( "startIndex" )
paragraph_end = element . get ( "endIndex" )
if not isinstance ( paragraph_start , int ) or not isinstance ( paragraph_end , int ):
continue
if paragraph_end > start_index and paragraph_start < end_index :
paragraph_starts . append ( paragraph_start )
return paragraph_starts or [ start_index ]
2025-12-25 20:11:38 +01:00
@server.tool ()
2026-02-01 14:47:32 -05:00
@handle_http_errors ( "update_paragraph_style" , service_type = "docs" )
2025-12-25 20:11:38 +01:00
@require_google_service ( "docs" , "docs_write" )
2026-02-01 14:47:32 -05:00
async def update_paragraph_style (
2025-12-25 20:11:38 +01:00
service : Any ,
user_google_email : str ,
document_id : str ,
start_index : int ,
end_index : int ,
2026-02-01 14:47:32 -05:00
heading_level : int = None ,
2025-12-25 20:11:38 +01:00
alignment : str = None ,
line_spacing : float = None ,
indent_first_line : float = None ,
indent_start : float = None ,
indent_end : float = None ,
space_above : float = None ,
space_below : float = None ,
2026-02-08 18:15:36 -05:00
list_type : str = None ,
2026-02-08 15:42:58 -05:00
list_nesting_level : int = None ,
2025-12-25 20:11:38 +01:00
) -> str :
"""
2026-02-08 15:42:58 -05:00
Apply paragraph-level formatting, heading styles, and/or list formatting to a range in a Google Doc.
2025-12-25 20:11:38 +01:00
2026-02-01 14:47:32 -05:00
This tool can apply named heading styles (H1-H6) for semantic document structure,
2026-02-08 15:42:58 -05:00
create bulleted or numbered lists with nested indentation, and customize paragraph
properties like alignment, spacing, and indentation. All operations can be applied
in a single call.
2025-12-25 20:11:38 +01:00
Args:
user_google_email: User's Google email address
document_id: Document ID to modify
start_index: Start position (1-based)
2026-02-01 14:47:32 -05:00
end_index: End position (exclusive) - should cover the entire paragraph
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1 = H1, 2 = H2, etc.)
Use for semantic document structure
2025-12-25 20:11:38 +01:00
alignment: Text alignment - 'START' (left), 'CENTER', 'END' (right), or 'JUSTIFIED'
line_spacing: Line spacing multiplier (1.0 = single, 1.5 = 1.5x, 2.0 = double)
indent_first_line: First line indent in points (e.g., 36 for 0.5 inch)
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points (e.g., 12 for one line)
space_below: Space below paragraph in points
2026-02-08 18:15:36 -05:00
list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers)
2026-02-08 15:42:58 -05:00
list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0)
Use higher levels for nested/indented list items
2025-12-25 20:11:38 +01:00
Returns:
str: Confirmation message with formatting details
2026-02-01 14:47:32 -05:00
Examples:
# Apply H1 heading style
update_paragraph_style(document_id="...", start_index=1, end_index=20, heading_level=1)
2026-02-08 15:42:58 -05:00
# Create a bulleted list
2026-02-01 14:47:32 -05:00
update_paragraph_style(document_id="...", start_index=1, end_index=50,
2026-02-08 18:15:36 -05:00
list_type="UNORDERED")
2026-02-08 15:42:58 -05:00
# Create a nested numbered list item
update_paragraph_style(document_id="...", start_index=1, end_index=30,
2026-02-08 18:15:36 -05:00
list_type="ORDERED", list_nesting_level=1)
2026-02-01 14:47:32 -05:00
# Apply H2 heading with custom spacing
update_paragraph_style(document_id="...", start_index=1, end_index=30,
heading_level=2, space_above=18, space_below=12)
2026-02-08 15:42:58 -05:00
# Center-align a paragraph with double spacing
update_paragraph_style(document_id="...", start_index=1, end_index=50,
alignment="CENTER", line_spacing=2.0)
2025-12-25 20:11:38 +01:00
"""
logger . info (
2026-02-01 14:47:32 -05:00
f "[update_paragraph_style] Doc= { document_id } , Range: { start_index } - { end_index } "
2025-12-25 20:11:38 +01:00
)
# Validate range
if start_index < 1 :
return "Error: start_index must be >= 1"
if end_index <= start_index :
return "Error: end_index must be greater than start_index"
2026-02-08 15:42:58 -05:00
# Validate list parameters
2026-02-08 18:15:36 -05:00
list_type_value = list_type
if list_type_value is not None :
2026-02-08 16:09:37 -05:00
# Coerce non-string inputs to string before normalization to avoid AttributeError
2026-02-08 18:15:36 -05:00
if not isinstance ( list_type_value , str ):
list_type_value = str ( list_type_value )
2026-02-08 15:42:58 -05:00
valid_list_types = [ "UNORDERED" , "ORDERED" ]
2026-02-08 18:15:36 -05:00
normalized_list_type = list_type_value . upper ()
if normalized_list_type not in valid_list_types :
return f "Error: list_type must be one of: { ', ' . join ( valid_list_types ) } "
list_type_value = normalized_list_type
2026-02-08 15:42:58 -05:00
if list_nesting_level is not None :
2026-02-08 18:15:36 -05:00
if list_type_value is None :
return "Error: list_nesting_level requires list_type parameter"
2026-02-08 17:38:20 -05:00
if not isinstance ( list_nesting_level , int ):
return "Error: list_nesting_level must be an integer"
2026-02-08 15:42:58 -05:00
if list_nesting_level < 0 or list_nesting_level > 8 :
return "Error: list_nesting_level must be between 0 and 8"
2025-12-25 20:11:38 +01:00
# Build paragraph style object
paragraph_style = {}
fields = []
2026-02-01 14:47:32 -05:00
# Handle heading level (named style)
if heading_level is not None :
if heading_level < 0 or heading_level > 6 :
return "Error: heading_level must be between 0 (normal text) and 6"
if heading_level == 0 :
paragraph_style [ "namedStyleType" ] = "NORMAL_TEXT"
else :
paragraph_style [ "namedStyleType" ] = f "HEADING_ { heading_level } "
fields . append ( "namedStyleType" )
# Handle alignment
2025-12-25 20:11:38 +01:00
if alignment is not None :
valid_alignments = [ "START" , "CENTER" , "END" , "JUSTIFIED" ]
alignment_upper = alignment . upper ()
if alignment_upper not in valid_alignments :
return f "Error: Invalid alignment ' { alignment } '. Must be one of: { valid_alignments } "
paragraph_style [ "alignment" ] = alignment_upper
fields . append ( "alignment" )
2026-02-01 14:47:32 -05:00
# Handle line spacing
2025-12-25 20:11:38 +01:00
if line_spacing is not None :
if line_spacing <= 0 :
return "Error: line_spacing must be positive"
paragraph_style [ "lineSpacing" ] = line_spacing * 100 # Convert to percentage
fields . append ( "lineSpacing" )
2026-02-01 14:47:32 -05:00
# Handle indentation
2025-12-25 20:11:38 +01:00
if indent_first_line is not None :
2026-02-01 12:07:00 -05:00
paragraph_style [ "indentFirstLine" ] = {
"magnitude" : indent_first_line ,
"unit" : "PT" ,
}
2025-12-25 20:11:38 +01:00
fields . append ( "indentFirstLine" )
if indent_start is not None :
paragraph_style [ "indentStart" ] = { "magnitude" : indent_start , "unit" : "PT" }
fields . append ( "indentStart" )
if indent_end is not None :
paragraph_style [ "indentEnd" ] = { "magnitude" : indent_end , "unit" : "PT" }
fields . append ( "indentEnd" )
2026-02-01 14:47:32 -05:00
# Handle spacing
2025-12-25 20:11:38 +01:00
if space_above is not None :
paragraph_style [ "spaceAbove" ] = { "magnitude" : space_above , "unit" : "PT" }
fields . append ( "spaceAbove" )
if space_below is not None :
paragraph_style [ "spaceBelow" ] = { "magnitude" : space_below , "unit" : "PT" }
fields . append ( "spaceBelow" )
2026-02-08 15:42:58 -05:00
# Create batch update requests
requests = []
2025-12-25 20:11:38 +01:00
2026-02-08 15:42:58 -05:00
# Add paragraph style update if we have any style changes
if paragraph_style :
requests . append (
{
"updateParagraphStyle" : {
"range" : { "startIndex" : start_index , "endIndex" : end_index },
"paragraphStyle" : paragraph_style ,
"fields" : "," . join ( fields ),
}
2025-12-25 20:11:38 +01:00
}
2026-02-08 15:42:58 -05:00
)
# Add list creation if requested
2026-02-08 18:15:36 -05:00
if list_type_value is not None :
2026-02-08 15:42:58 -05:00
# Default to level 0 if not specified
nesting_level = list_nesting_level if list_nesting_level is not None else 0
2026-02-08 17:38:20 -05:00
try :
2026-02-08 18:15:36 -05:00
paragraph_start_indices = None
if nesting_level > 0 :
paragraph_start_indices = await _get_paragraph_start_indices_in_range (
service , document_id , start_index , end_index
)
2026-02-08 17:38:20 -05:00
list_requests = create_bullet_list_request (
2026-02-08 18:15:36 -05:00
start_index ,
end_index ,
list_type_value ,
nesting_level ,
paragraph_start_indices = paragraph_start_indices ,
2026-02-08 15:42:58 -05:00
)
2026-02-08 17:38:20 -05:00
requests . extend ( list_requests )
except ValueError as e :
return f "Error: { e } "
2026-02-08 15:42:58 -05:00
# Validate we have at least one operation
if not requests :
return f "No paragraph style changes or list creation specified for document { document_id } "
2025-12-25 20:11:38 +01:00
await asyncio . to_thread (
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : requests })
. execute
)
2026-02-01 14:47:32 -05:00
# Build summary
summary_parts = []
if "namedStyleType" in paragraph_style :
summary_parts . append ( paragraph_style [ "namedStyleType" ])
format_fields = [ f for f in fields if f != "namedStyleType" ]
if format_fields :
summary_parts . append ( ", " . join ( format_fields ))
2026-02-08 18:15:36 -05:00
if list_type_value is not None :
list_desc = f " { list_type_value . lower () } list"
2026-02-08 15:42:58 -05:00
if list_nesting_level is not None and list_nesting_level > 0 :
list_desc += f " (level { list_nesting_level } )"
summary_parts . append ( list_desc )
2025-12-25 20:11:38 +01:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
2026-02-08 15:42:58 -05:00
return f "Applied paragraph formatting ( { ', ' . join ( summary_parts ) } ) to range { start_index } - { end_index } in document { document_id } . Link: { link } "
2025-12-25 20:11:38 +01:00
2026-02-18 19:15:56 -08:00
@server.tool ()
@handle_http_errors ( "get_doc_as_markdown" , is_read_only = True , service_type = "docs" )
@require_multiple_services (
[
{
"service_type" : "drive" ,
"scopes" : "drive_read" ,
"param_name" : "drive_service" ,
},
{ "service_type" : "docs" , "scopes" : "docs_read" , "param_name" : "docs_service" },
]
)
async def get_doc_as_markdown (
drive_service : Any ,
docs_service : Any ,
user_google_email : str ,
document_id : str ,
include_comments : bool = True ,
comment_mode : str = "inline" ,
include_resolved : bool = False ,
) -> str :
"""
Reads a Google Doc and returns it as clean Markdown with optional comment context.
Unlike get_doc_content which returns plain text, this tool preserves document
formatting as Markdown: headings, bold/italic/strikethrough, links, code spans,
ordered/unordered lists with nesting, and tables.
When comments are included (the default), each comment's anchor text — the specific
text the comment was attached to — is preserved, giving full context for the discussion.
Args:
user_google_email: User's Google email address
document_id: ID of the Google Doc (or full URL)
include_comments: Whether to include comments (default: True)
comment_mode: How to display comments:
- "inline": Footnote-style references placed at the anchor text location (default)
- "appendix": All comments grouped at the bottom with blockquoted anchor text
- "none": No comments included
include_resolved: Whether to include resolved comments (default: False)
Returns:
str: The document content as Markdown, optionally with comments
"""
2026-02-18 19:26:31 -08:00
# Extract doc ID from URL if a full URL was provided
url_match = re . search ( r "/d/([\w-]+)" , document_id )
if url_match :
document_id = url_match . group ( 1 )
valid_modes = ( "inline" , "appendix" , "none" )
if comment_mode not in valid_modes :
return f "Error: comment_mode must be one of { valid_modes } , got ' { comment_mode } '"
2026-02-18 19:15:56 -08:00
logger . info (
f "[get_doc_as_markdown] Doc= { document_id } , comments= { include_comments } , mode= { comment_mode } "
)
# Fetch document content via Docs API
doc = await asyncio . to_thread (
docs_service . documents () . get ( documentId = document_id ) . execute
)
markdown = convert_doc_to_markdown ( doc )
if not include_comments or comment_mode == "none" :
return markdown
# Fetch comments via Drive API
all_comments = []
page_token = None
while True :
response = await asyncio . to_thread (
drive_service . comments ()
. list (
fileId = document_id ,
fields = "comments(id,content,author,createdTime,modifiedTime,"
"resolved,quotedFileContent,"
"replies(id,content,author,createdTime,modifiedTime)),"
"nextPageToken" ,
includeDeleted = False ,
pageToken = page_token ,
)
. execute
)
all_comments . extend ( response . get ( "comments" , []))
page_token = response . get ( "nextPageToken" )
if not page_token :
break
comments = parse_drive_comments (
{ "comments" : all_comments }, include_resolved = include_resolved
)
if not comments :
return markdown
if comment_mode == "inline" :
return format_comments_inline ( markdown , comments )
else :
appendix = format_comments_appendix ( comments )
return markdown . rstrip ( " \n " ) + " \n\n " + appendix
2026-03-01 10:40:19 -08:00
@server.tool ()
@handle_http_errors ( "insert_doc_tab" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def insert_doc_tab (
service : Any ,
user_google_email : str ,
document_id : str ,
title : str ,
index : int ,
parent_tab_id : Optional [ str ] = None ,
) -> str :
"""
Inserts a new tab into a Google Doc.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
title: Title of the new tab
index: Position index for the new tab (0-based among sibling tabs)
parent_tab_id: Optional ID of a parent tab to nest the new tab under
Returns:
str: Confirmation message with document link
"""
logger . info ( f "[insert_doc_tab] Doc= { document_id } , title=' { title } ', index= { index } " )
request = create_insert_doc_tab_request ( title , index , parent_tab_id )
2026-03-03 17:46:50 -05:00
result = await asyncio . to_thread (
2026-03-01 10:40:19 -08:00
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : [ request ]})
. execute
)
2026-03-03 17:46:50 -05:00
# Extract the new tab ID from the batchUpdate response
tab_id = None
if "replies" in result and result [ "replies" ]:
reply = result [ "replies" ][ 0 ]
if "createDocumentTab" in reply :
tab_id = reply [ "createDocumentTab" ] . get ( "tabProperties" , {}) . get ( "tabId" )
2026-03-01 10:40:19 -08:00
link = f "https://docs.google.com/document/d/ { document_id } /edit"
msg = f "Inserted tab ' { title } ' at index { index } in document { document_id } ."
2026-03-03 17:46:50 -05:00
if tab_id :
msg += f " Tab ID: { tab_id } ."
2026-03-01 10:40:19 -08:00
if parent_tab_id :
msg += f " Nested under parent tab { parent_tab_id } ."
return f " { msg } Link: { link } "
@server.tool ()
@handle_http_errors ( "delete_doc_tab" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def delete_doc_tab (
service : Any ,
user_google_email : str ,
document_id : str ,
tab_id : str ,
) -> str :
"""
Deletes a tab from a Google Doc by its tab ID.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
tab_id: ID of the tab to delete (use inspect_doc_structure to find tab IDs)
Returns:
str: Confirmation message with document link
"""
logger . info ( f "[delete_doc_tab] Doc= { document_id } , tab_id=' { tab_id } '" )
request = create_delete_doc_tab_request ( tab_id )
await asyncio . to_thread (
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : [ request ]})
. execute
)
link = f "https://docs.google.com/document/d/ { document_id } /edit"
return f "Deleted tab ' { tab_id } ' from document { document_id } . Link: { link } "
@server.tool ()
@handle_http_errors ( "update_doc_tab" , service_type = "docs" )
@require_google_service ( "docs" , "docs_write" )
async def update_doc_tab (
service : Any ,
user_google_email : str ,
document_id : str ,
tab_id : str ,
title : str ,
) -> str :
"""
Renames a tab in a Google Doc.
Args:
user_google_email: User's Google email address
document_id: ID of the document to update
tab_id: ID of the tab to rename (use inspect_doc_structure to find tab IDs)
title: New title for the tab
Returns:
str: Confirmation message with document link
"""
2026-03-03 17:46:50 -05:00
logger . info (
f "[update_doc_tab] Doc= { document_id } , tab_id=' { tab_id } ', title=' { title } '"
)
2026-03-01 10:40:19 -08:00
request = create_update_doc_tab_request ( tab_id , title )
await asyncio . to_thread (
service . documents ()
. batchUpdate ( documentId = document_id , body = { "requests" : [ request ]})
. execute
)
link = f "https://docs.google.com/document/d/ { document_id } /edit"
2026-03-03 17:46:50 -05:00
return (
f "Renamed tab ' { tab_id } ' to ' { title } ' in document { document_id } . Link: { link } "
)
2026-03-01 10:40:19 -08:00
2025-07-01 18:56:53 -07:00
# Create comment management tools for documents
_comment_tools = create_comment_tools ( "document" , "document_id" )
2025-06-23 13:18:56 +01:00
2025-07-01 18:56:53 -07:00
# Extract and register the functions
2026-03-01 12:50:40 -05:00
list_document_comments = _comment_tools [ "list_comments" ]
manage_document_comment = _comment_tools [ "manage_comment" ]