Skip to main content
Glama
helpers.py49.4 kB
""" Helper functions for Google Docs API operations. Ported from googleDocsApiHelpers.ts """ from typing import Any from fastmcp.exceptions import ToolError from google_docs_mcp.types import ( TextStyleArgs, ParagraphStyleArgs, TextRange, TabInfo, hex_to_rgb_color, NotImplementedError, ) from google_docs_mcp.utils import log # --- Constants --- MAX_BATCH_UPDATE_REQUESTS = 50 # --- Core Helper to Execute Batch Updates --- def execute_batch_update_sync(docs, document_id: str, requests: list[dict]) -> dict | None: """ Execute a batch update request on a Google Document. Args: docs: Google Docs API client document_id: The document ID requests: List of update requests Returns: Batch update response data Raises: ToolError: For client-facing errors Exception: For internal errors """ if not requests: return {} if len(requests) > MAX_BATCH_UPDATE_REQUESTS: log( f"Attempting batch update with {len(requests)} requests, " f"exceeding typical limits. May fail." ) try: response = ( docs.documents() .batchUpdate(documentId=document_id, body={"requests": requests}) .execute() ) return response except Exception as e: error_message = str(e) log(f"Google API batchUpdate Error for doc {document_id}: {error_message}") # Handle common API errors if "404" in error_message: raise ToolError(f"Document not found (ID: {document_id}). Check the ID.") if "403" in error_message: raise ToolError( f"Permission denied for document (ID: {document_id}). " f"Ensure the authenticated user has edit access." ) if "400" in error_message: raise ToolError(f"Invalid request sent to Google Docs API: {error_message}") raise Exception(f"Google API Error: {error_message}") # --- Text Finding Helper --- def find_text_range( docs, document_id: str, text_to_find: str, instance: int = 1 ) -> TextRange | None: """ Find a specific instance of text within a document. Args: docs: Google Docs API client document_id: The document ID text_to_find: The text string to locate instance: Which instance to find (1-based) Returns: TextRange with start and end indices, or None if not found Raises: UserError: For permission/not found errors """ try: # Request detailed document structure res = ( docs.documents() .get( documentId=document_id, fields="body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))", ) .execute() ) body = res.get("body", {}) content = body.get("content", []) if not content: log(f"No content found in document {document_id}") return None # Collect text segments with their indices full_text = "" segments: list[dict] = [] def collect_text_from_content(content_list: list) -> None: nonlocal full_text for element in content_list: # Handle paragraph elements paragraph = element.get("paragraph", {}) if paragraph.get("elements"): for pe in paragraph["elements"]: text_run = pe.get("textRun", {}) if ( text_run.get("content") and pe.get("startIndex") is not None and pe.get("endIndex") is not None ): text_content = text_run["content"] full_text += text_content segments.append( { "text": text_content, "start": pe["startIndex"], "end": pe["endIndex"], } ) # Handle table elements table = element.get("table", {}) if table.get("tableRows"): for row in table["tableRows"]: for cell in row.get("tableCells", []): if cell.get("content"): collect_text_from_content(cell["content"]) collect_text_from_content(content) # Sort segments by starting position segments.sort(key=lambda x: x["start"]) log( f"Document {document_id} contains {len(segments)} text segments " f"and {len(full_text)} characters in total." ) # Find the specified instance of the text start_index = -1 end_index = -1 found_count = 0 search_start_index = 0 while found_count < instance: current_index = full_text.find(text_to_find, search_start_index) if current_index == -1: log( f'Search text "{text_to_find}" not found for instance ' f"{found_count + 1} (requested: {instance})" ) break found_count += 1 log( f'Found instance {found_count} of "{text_to_find}" ' f"at position {current_index} in full text" ) if found_count == instance: target_start = current_index target_end = current_index + len(text_to_find) current_pos = 0 log(f"Target text range in full text: {target_start}-{target_end}") for seg in segments: seg_start = current_pos seg_length = len(seg["text"]) seg_end = seg_start + seg_length # Map from reconstructed text position to actual document indices if ( start_index == -1 and target_start >= seg_start and target_start < seg_end ): start_index = seg["start"] + (target_start - seg_start) log( f"Mapped start to segment {seg['start']}-{seg['end']}, " f"position {start_index}" ) if target_end > seg_start and target_end <= seg_end: end_index = seg["start"] + (target_end - seg_start) log( f"Mapped end to segment {seg['start']}-{seg['end']}, " f"position {end_index}" ) break current_pos = seg_end if start_index == -1 or end_index == -1: log( f'Failed to map text "{text_to_find}" instance {instance} ' f"to actual document indices" ) start_index = -1 end_index = -1 search_start_index = current_index + 1 found_count -= 1 continue log( f'Successfully mapped "{text_to_find}" to document range ' f"{start_index}-{end_index}" ) return TextRange(start_index=start_index, end_index=end_index) # Prepare for next search iteration search_start_index = current_index + 1 log( f'Could not find instance {instance} of text "{text_to_find}" ' f"in document {document_id}" ) return None except Exception as e: error_message = str(e) log( f'Error finding text "{text_to_find}" in doc {document_id}: {error_message}' ) if "404" in error_message: raise ToolError( f"Document not found while searching text (ID: {document_id})." ) if "403" in error_message: raise ToolError( f"Permission denied while searching text in doc {document_id}." ) raise Exception(f"Failed to retrieve doc for text searching: {error_message}") # --- Paragraph Boundary Helper --- def get_paragraph_range( docs, document_id: str, index_within: int ) -> TextRange | None: """ Find the paragraph boundaries containing a specific index. Args: docs: Google Docs API client document_id: The document ID index_within: An index within the target paragraph Returns: TextRange with paragraph start and end indices, or None if not found Raises: UserError: For permission/not found errors """ try: log(f"Finding paragraph containing index {index_within} in document {document_id}") res = ( docs.documents() .get( documentId=document_id, fields="body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))", ) .execute() ) body = res.get("body", {}) content = body.get("content", []) if not content: log(f"No content found in document {document_id}") return None def find_paragraph_in_content( content_list: list, ) -> TextRange | None: for element in content_list: start_idx = element.get("startIndex") end_idx = element.get("endIndex") if start_idx is not None and end_idx is not None: if index_within >= start_idx and index_within < end_idx: # If it's a paragraph, we've found our target if element.get("paragraph"): log( f"Found paragraph containing index {index_within}, " f"range: {start_idx}-{end_idx}" ) return TextRange(start_index=start_idx, end_index=end_idx) # If it's a table, search cells recursively table = element.get("table", {}) if table.get("tableRows"): log( f"Index {index_within} is within a table, searching cells..." ) for row in table["tableRows"]: for cell in row.get("tableCells", []): if cell.get("content"): result = find_paragraph_in_content( cell["content"] ) if result: return result log( f"Index {index_within} is within element " f"({start_idx}-{end_idx}) but not in a paragraph" ) return None result = find_paragraph_in_content(content) if not result: log(f"Could not find paragraph containing index {index_within}") else: log( f"Returning paragraph range: {result.start_index}-{result.end_index}" ) return result except Exception as e: error_message = str(e) log( f"Error getting paragraph range for index {index_within} " f"in doc {document_id}: {error_message}" ) if "404" in error_message: raise ToolError( f"Document not found while finding paragraph (ID: {document_id})." ) if "403" in error_message: raise ToolError( f"Permission denied while accessing doc {document_id}." ) raise Exception(f"Failed to find paragraph: {error_message}") def get_paragraph_range_from_document( document: dict, index_within: int, tab_id: str | None = None ) -> TextRange | None: """ Find the paragraph boundaries containing a specific index using pre-fetched document data. This is more efficient than get_paragraph_range() when you already have the document data, as it avoids an additional API call. Args: document: The full document data dict (from docs.documents().get()) index_within: An index within the target paragraph tab_id: Optional tab ID to search within (uses first tab if not specified) Returns: TextRange with paragraph start and end indices, or None if not found """ log(f"Finding paragraph containing index {index_within} in document data") # Get the body content - handle both tab-based and direct body structure body = None tabs = document.get("tabs", []) if tabs: # Document has tabs structure if tab_id: # Find the specific tab for tab in tabs: tab_props = tab.get("tabProperties", {}) if tab_props.get("tabId") == tab_id: body = tab.get("documentTab", {}).get("body", {}) break if not body and tabs: # Use first tab body = tabs[0].get("documentTab", {}).get("body", {}) else: # Legacy document structure without tabs body = document.get("body", {}) if not body: log("No body content found in document data") return None content = body.get("content", []) if not content: log("No content found in document body") return None def find_paragraph_in_content(content_list: list) -> TextRange | None: for element in content_list: start_idx = element.get("startIndex") end_idx = element.get("endIndex") if start_idx is not None and end_idx is not None: if index_within >= start_idx and index_within < end_idx: # If it's a paragraph, we've found our target # Use "in" check because element.get("paragraph") returns {} # which is falsy even when the key exists if "paragraph" in element: log( f"Found paragraph containing index {index_within}, " f"range: {start_idx}-{end_idx}" ) return TextRange(start_index=start_idx, end_index=end_idx) # If it's a table, search cells recursively if "table" in element: table = element["table"] if table.get("tableRows"): log(f"Index {index_within} is within a table, searching cells...") for row in table["tableRows"]: for cell in row.get("tableCells", []): if cell.get("content"): result = find_paragraph_in_content(cell["content"]) if result: return result log( f"Index {index_within} is within element " f"({start_idx}-{end_idx}) but not in a paragraph" ) return None result = find_paragraph_in_content(content) if not result: log(f"Could not find paragraph containing index {index_within}") else: log(f"Returning paragraph range: {result.start_index}-{result.end_index}") return result # --- Style Request Builders --- def build_update_text_style_request( start_index: int, end_index: int, style: TextStyleArgs ) -> dict | None: """ Build an updateTextStyle request for the Google Docs API. Args: start_index: Starting index of text range end_index: Ending index of text range style: Text style arguments to apply Returns: Dictionary with 'request' and 'fields' keys, or None if no styles Raises: UserError: If color format is invalid """ text_style: dict[str, Any] = {} fields_to_update: list[str] = [] if style.bold is not None: text_style["bold"] = style.bold fields_to_update.append("bold") if style.italic is not None: text_style["italic"] = style.italic fields_to_update.append("italic") if style.underline is not None: text_style["underline"] = style.underline fields_to_update.append("underline") if style.strikethrough is not None: text_style["strikethrough"] = style.strikethrough fields_to_update.append("strikethrough") if style.font_size is not None: text_style["fontSize"] = {"magnitude": style.font_size, "unit": "PT"} fields_to_update.append("fontSize") if style.font_family is not None: text_style["weightedFontFamily"] = {"fontFamily": style.font_family} fields_to_update.append("weightedFontFamily") if style.foreground_color is not None: rgb_color = hex_to_rgb_color(style.foreground_color) if not rgb_color: raise ToolError( f"Invalid foreground hex color format: {style.foreground_color}" ) text_style["foregroundColor"] = {"color": {"rgbColor": rgb_color}} fields_to_update.append("foregroundColor") if style.background_color is not None: rgb_color = hex_to_rgb_color(style.background_color) if not rgb_color: raise ToolError( f"Invalid background hex color format: {style.background_color}" ) text_style["backgroundColor"] = {"color": {"rgbColor": rgb_color}} fields_to_update.append("backgroundColor") if style.link_url is not None: text_style["link"] = {"url": style.link_url} fields_to_update.append("link") if not fields_to_update: return None request = { "updateTextStyle": { "range": {"startIndex": start_index, "endIndex": end_index}, "textStyle": text_style, "fields": ",".join(fields_to_update), } } return {"request": request, "fields": fields_to_update} def build_update_paragraph_style_request( start_index: int, end_index: int, style: ParagraphStyleArgs ) -> dict | None: """ Build an updateParagraphStyle request for the Google Docs API. Args: start_index: Starting index of paragraph range end_index: Ending index of paragraph range style: Paragraph style arguments to apply Returns: Dictionary with 'request' and 'fields' keys, or None if no styles """ paragraph_style: dict[str, Any] = {} fields_to_update: list[str] = [] log( f"Building paragraph style request for range {start_index}-{end_index} " f"with options: {style}" ) if style.alignment is not None: paragraph_style["alignment"] = style.alignment fields_to_update.append("alignment") log(f"Setting alignment to {style.alignment}") if style.indent_start is not None: paragraph_style["indentStart"] = { "magnitude": style.indent_start, "unit": "PT", } fields_to_update.append("indentStart") log(f"Setting left indent to {style.indent_start}pt") if style.indent_end is not None: paragraph_style["indentEnd"] = {"magnitude": style.indent_end, "unit": "PT"} fields_to_update.append("indentEnd") log(f"Setting right indent to {style.indent_end}pt") if style.space_above is not None: paragraph_style["spaceAbove"] = {"magnitude": style.space_above, "unit": "PT"} fields_to_update.append("spaceAbove") log(f"Setting space above to {style.space_above}pt") if style.space_below is not None: paragraph_style["spaceBelow"] = {"magnitude": style.space_below, "unit": "PT"} fields_to_update.append("spaceBelow") log(f"Setting space below to {style.space_below}pt") if style.named_style_type is not None: paragraph_style["namedStyleType"] = style.named_style_type fields_to_update.append("namedStyleType") log(f"Setting named style to {style.named_style_type}") if style.keep_with_next is not None: paragraph_style["keepWithNext"] = style.keep_with_next fields_to_update.append("keepWithNext") log(f"Setting keepWithNext to {style.keep_with_next}") if not fields_to_update: log("No paragraph styling options were provided") return None request = { "updateParagraphStyle": { "range": {"startIndex": start_index, "endIndex": end_index}, "paragraphStyle": paragraph_style, "fields": ",".join(fields_to_update), } } log(f"Created paragraph style request with fields: {', '.join(fields_to_update)}") return {"request": request, "fields": fields_to_update} # --- Specific Feature Helpers --- def create_table( docs, document_id: str, rows: int, columns: int, index: int ) -> dict | None: """ Insert a new table into a document. Args: docs: Google Docs API client document_id: The document ID rows: Number of rows columns: Number of columns index: Position to insert the table Returns: Batch update response Raises: UserError: If table dimensions are invalid """ if rows < 1 or columns < 1: raise ToolError("Table must have at least 1 row and 1 column.") request = { "insertTable": { "location": {"index": index}, "rows": rows, "columns": columns, } } return execute_batch_update_sync(docs, document_id, [request]) def insert_text(docs, document_id: str, text: str, index: int) -> dict | None: """ Insert text at a specific position in a document. Args: docs: Google Docs API client document_id: The document ID text: Text to insert index: Position to insert text Returns: Batch update response """ if not text: return {} request = {"insertText": {"location": {"index": index}, "text": text}} return execute_batch_update_sync(docs, document_id, [request]) def _validate_image_url(image_url: str) -> None: """ Validate that an image URL is accessible before sending to Google Docs API. Args: image_url: The URL to validate Raises: ToolError: If the URL is invalid or inaccessible """ import urllib.request from urllib.parse import urlparse from urllib.error import HTTPError, URLError # Validate URL format try: result = urlparse(image_url) if not all([result.scheme, result.netloc]): raise ValueError("Invalid URL") if result.scheme not in ('http', 'https'): raise ValueError("URL must use http or https") except Exception: raise ToolError(f"Invalid image URL format: {image_url}") # Check for Google Drive URLs and provide helpful guidance if 'drive.google.com' in result.netloc: # For Drive URLs with the /uc endpoint, convert to download format if '/uc' in image_url: # Extract file ID and create proper download URL import re file_id_match = re.search(r'[?&]id=([^&]+)', image_url) if file_id_match: file_id = file_id_match.group(1) # Return the download URL format that works with Google Docs suggested_url = f"https://drive.google.com/uc?export=download&id={file_id}" if 'export=download' not in image_url: raise ToolError( f"Google Drive sharing URLs must use the download format. " f"Try this URL instead: {suggested_url}" ) # Check if URL is accessible try: # Create a HEAD request to check accessibility without downloading the full image req = urllib.request.Request(image_url, method='HEAD') req.add_header('User-Agent', 'Mozilla/5.0 (compatible; GoogleDocsBot/1.0)') with urllib.request.urlopen(req, timeout=10) as response: status_code = response.getcode() content_type = response.headers.get('Content-Type', '') if status_code != 200: raise ToolError( f"Image URL returned status {status_code}: {image_url}. " "The image must be publicly accessible." ) # Verify it's an image (skip for Google Drive download URLs as they may redirect) is_drive_download = 'drive.google.com/uc' in image_url and 'export=download' in image_url if not is_drive_download and content_type and not content_type.startswith('image/'): # Provide helpful error for Google Drive URLs with wrong format if 'drive.google.com' in image_url: raise ToolError( f"URL does not point to an image (Content-Type: {content_type}): {image_url}. " "Google Drive sharing links require public access and the download format. " "Make sure the file is shared publicly and use: " "https://drive.google.com/uc?export=download&id=FILE_ID" ) raise ToolError( f"URL does not point to an image (Content-Type: {content_type}): {image_url}. " "Expected image/* content type." ) except HTTPError as e: raise ToolError( f"Image URL returned HTTP {e.code} error: {image_url}. " "Please verify the URL is correct and publicly accessible." ) except URLError as e: raise ToolError( f"Cannot access image URL: {image_url}. " f"Error: {str(e.reason)}. " "The image must be publicly accessible on the internet." ) except TimeoutError: raise ToolError( f"Timeout accessing image URL: {image_url}. " "The server did not respond in time." ) except ToolError: raise except Exception as e: raise ToolError( f"Failed to validate image URL: {image_url}. " f"Error: {str(e)}" ) def insert_inline_image( docs, document_id: str, image_url: str, index: int, width: float | None = None, height: float | None = None, ) -> dict | None: """ Insert an inline image from a URL. Args: docs: Google Docs API client document_id: The document ID image_url: Publicly accessible URL to the image index: Position to insert the image width: Optional width in points height: Optional height in points Returns: Batch update response Raises: ToolError: If URL is invalid or inaccessible """ # Validate URL is accessible before attempting insertion _validate_image_url(image_url) # If this is a Google Drive URL, ensure it has public permissions if 'drive.google.com' in image_url: import re from google_docs_mcp.auth import get_drive_client # Extract file ID from various Drive URL formats file_id_match = re.search(r'[?&]id=([^&]+)', image_url) if file_id_match: file_id = file_id_match.group(1) log(f"Setting public permissions for Google Drive file {file_id}") try: drive = get_drive_client() # Make the file publicly readable so Google Docs can access it permission = { "type": "anyone", "role": "reader" } drive.permissions().create( fileId=file_id, body=permission ).execute() log(f"Successfully set public permissions for Drive file {file_id}") except Exception as e: log(f"Warning: Could not set public permissions for Drive file {file_id}: {e}") raise ToolError( f"Failed to set public permissions on Google Drive file {file_id}. " "Please ensure the file is publicly accessible or share it with 'Anyone with the link'." ) request: dict[str, Any] = { "insertInlineImage": {"location": {"index": index}, "uri": image_url} } if width and height: request["insertInlineImage"]["objectSize"] = { "height": {"magnitude": height, "unit": "PT"}, "width": {"magnitude": width, "unit": "PT"}, } return execute_batch_update_sync(docs, document_id, [request]) # --- Tab Management Helpers --- def get_all_tabs(doc: dict) -> list[TabInfo]: """ Get all tabs from a document in a flat list with hierarchy info. Args: doc: Google Document response object Returns: List of TabInfo objects with nesting level information """ all_tabs: list[TabInfo] = [] tabs = doc.get("tabs", []) if not tabs: return all_tabs def add_tab_and_children(tab: dict, level: int) -> None: props = tab.get("tabProperties", {}) doc_tab = tab.get("documentTab") text_length = None if doc_tab: text_length = get_tab_text_length(doc_tab) tab_info = TabInfo( tab_id=props.get("tabId", ""), title=props.get("title", "Untitled"), index=props.get("index"), parent_tab_id=props.get("parentTabId"), level=level, text_length=text_length, ) all_tabs.append(tab_info) for child_tab in tab.get("childTabs", []): add_tab_and_children(child_tab, level + 1) for tab in tabs: add_tab_and_children(tab, 0) return all_tabs def get_tab_text_length(document_tab: dict) -> int: """ Get the text length from a DocumentTab. Args: document_tab: The DocumentTab object Returns: Total character count """ total_length = 0 body = document_tab.get("body", {}) content = body.get("content", []) for element in content: # Handle paragraphs paragraph = element.get("paragraph", {}) if paragraph.get("elements"): for pe in paragraph["elements"]: text_run = pe.get("textRun", {}) if text_run.get("content"): total_length += len(text_run["content"]) # Handle tables table = element.get("table", {}) if table.get("tableRows"): for row in table["tableRows"]: for cell in row.get("tableCells", []): for cell_element in cell.get("content", []): cell_paragraph = cell_element.get("paragraph", {}) for pe in cell_paragraph.get("elements", []): text_run = pe.get("textRun", {}) if text_run.get("content"): total_length += len(text_run["content"]) return total_length def find_tab_by_id(doc: dict, tab_id: str) -> dict | None: """ Find a specific tab by ID in a document. Args: doc: Google Document response object tab_id: The tab ID to search for Returns: The tab object if found, None otherwise """ tabs = doc.get("tabs", []) if not tabs: return None def search_tabs(tabs_list: list) -> dict | None: for tab in tabs_list: props = tab.get("tabProperties", {}) if props.get("tabId") == tab_id: return tab # Recursively search child tabs child_tabs = tab.get("childTabs", []) if child_tabs: found = search_tabs(child_tabs) if found: return found return None return search_tabs(tabs) # --- Not Implemented Helpers --- def detect_and_format_lists( docs, document_id: str, start_index: int | None = None, end_index: int | None = None ) -> dict: """ Detect and format lists in a document. NOT IMPLEMENTED. """ log("detect_and_format_lists is not implemented.") raise NotImplementedError( "Automatic list detection and formatting is not yet implemented." ) def find_paragraphs_matching_style( docs, document_id: str, style_criteria: dict ) -> list[TextRange]: """ Find paragraphs matching style criteria. NOT IMPLEMENTED. """ log("find_paragraphs_matching_style is not implemented.") raise NotImplementedError( "Finding paragraphs by style criteria is not yet implemented." ) # --- Bulk Operations Helpers --- def chunk_requests(requests: list[dict], chunk_size: int = 50) -> list[list[dict]]: """ Split requests into chunks of specified size. Args: requests: List of request dictionaries chunk_size: Maximum number of requests per chunk (default: 50) Returns: List of request chunks """ if chunk_size <= 0: raise ValueError("chunk_size must be positive") return [requests[i : i + chunk_size] for i in range(0, len(requests), chunk_size)] # --- List & Bullet Helpers --- def build_create_paragraph_bullets_request( start_index: int, end_index: int, list_type: str = "UNORDERED", nesting_level: int = 0, tab_id: str | None = None, ) -> dict: """ Build a createParagraphBullets request. Args: start_index: Starting index of range (inclusive, 1-based) end_index: Ending index of range (exclusive) list_type: Type of list (UNORDERED, ORDERED_DECIMAL, etc.) nesting_level: Nesting level (0-8) tab_id: Optional tab ID Returns: Request dictionary for Google Docs API """ # Map list types to bullet presets bullet_presets = { "UNORDERED": "BULLET_DISC_CIRCLE_SQUARE", "ORDERED_DECIMAL": "NUMBERED_DECIMAL_ALPHA_ROMAN", "ORDERED_ALPHA": "NUMBERED_UPPERLETTER_ALPHAROMAN", "ORDERED_ROMAN": "NUMBERED_UPPERROMAN_UPPERLETTER_DECIMAL", } request: dict[str, Any] = { "createParagraphBullets": { "range": {"startIndex": start_index, "endIndex": end_index}, "bulletPreset": bullet_presets.get(list_type, "BULLET_DISC_CIRCLE_SQUARE"), } } if tab_id: request["createParagraphBullets"]["range"]["tabId"] = tab_id return request # --- Text Replace Helpers --- def build_replace_all_text_request( find_text: str, replace_text: str, match_case: bool = True, tab_id: str | None = None, ) -> dict: """ Build a replaceAllText request. Args: find_text: Text to find replace_text: Text to replace it with match_case: Whether to match case tab_id: Optional tab ID Returns: Request dictionary for Google Docs API """ request: dict[str, Any] = { "replaceAllText": { "containsText": {"text": find_text, "matchCase": match_case}, "replaceText": replace_text, } } if tab_id: request["replaceAllText"]["tabId"] = tab_id return request # --- Table Finding Helper --- def find_table_at_index(docs, document_id: str, search_index: int) -> Any: """ Locate a table containing or near the specified index. Scans document structure for table elements and returns table boundaries and dimensions. Args: docs: Google Docs API client document_id: The document ID search_index: An index that should be within the table Returns: TableInfo with table boundaries and dimensions, or None if not found Raises: ToolError: For API errors """ from google_docs_mcp.types import TableInfo try: # Fetch document structure with minimal fields res = ( docs.documents() .get(documentId=document_id, fields="body(content(table,startIndex,endIndex))") .execute() ) # Scan for table elements for element in res.get("body", {}).get("content", []): if "table" in element: table_start = element.get("startIndex", 0) table_end = element.get("endIndex", 0) # Check if search_index falls within table boundaries if table_start <= search_index < table_end: table = element["table"] table_rows = table.get("tableRows", []) rows = len(table_rows) columns = ( len(table_rows[0].get("tableCells", [])) if rows > 0 else 0 ) return TableInfo( start_index=table_start, end_index=table_end, rows=rows, columns=columns, ) return None except Exception as e: error_message = str(e) log(f"Error finding table at index {search_index}: {error_message}") raise ToolError(f"Failed to find table: {error_message}") # --- Table Operation Helpers --- def build_insert_table_row_request( table_start_index: int, row_index: int, insert_below: bool = False ) -> dict: """ Build an insertTableRow request. Args: table_start_index: Index where the table starts row_index: Row index (0-based) insert_below: True to insert below, False to insert above Returns: Request dictionary for Google Docs API """ return { "insertTableRow": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": row_index, "columnIndex": 0, }, "insertBelow": insert_below, } } def build_delete_table_row_request(table_start_index: int, row_index: int) -> dict: """ Build a deleteTableRow request. Args: table_start_index: Index where the table starts row_index: Row index (0-based) Returns: Request dictionary for Google Docs API """ return { "deleteTableRow": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": row_index, "columnIndex": 0, } } } def build_insert_table_column_request( table_start_index: int, column_index: int, insert_right: bool = False ) -> dict: """ Build an insertTableColumn request. Args: table_start_index: Index where the table starts column_index: Column index (0-based) insert_right: True to insert right, False to insert left Returns: Request dictionary for Google Docs API """ return { "insertTableColumn": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": 0, "columnIndex": column_index, }, "insertRight": insert_right, } } def build_delete_table_column_request(table_start_index: int, column_index: int) -> dict: """ Build a deleteTableColumn request. Args: table_start_index: Index where the table starts column_index: Column index (0-based) Returns: Request dictionary for Google Docs API """ return { "deleteTableColumn": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": 0, "columnIndex": column_index, } } } def build_update_table_cell_style_request( table_start_index: int, row_index: int, column_index: int, background_color: str | None = None, padding_top: float | None = None, padding_bottom: float | None = None, padding_left: float | None = None, padding_right: float | None = None, border_top_color: str | None = None, border_top_width: float | None = None, border_bottom_color: str | None = None, border_bottom_width: float | None = None, border_left_color: str | None = None, border_left_width: float | None = None, border_right_color: str | None = None, border_right_width: float | None = None, ) -> dict | None: """ Build an updateTableCellStyle request. Args: table_start_index: Index where the table starts row_index: Row index (0-based) column_index: Column index (0-based) background_color: Background color hex (e.g., "#FF0000") padding_top: Top padding in points padding_bottom: Bottom padding in points padding_left: Left padding in points padding_right: Right padding in points border_top_color: Top border color hex border_top_width: Top border width in points border_bottom_color: Bottom border color hex border_bottom_width: Bottom border width in points border_left_color: Left border color hex border_left_width: Left border width in points border_right_color: Right border color hex border_right_width: Right border width in points Returns: Request dictionary or None if no styles provided """ table_cell_style: dict[str, Any] = {} fields: list[str] = [] # Background color if background_color: rgb = hex_to_rgb_color(background_color) if rgb: table_cell_style["backgroundColor"] = {"color": {"rgbColor": rgb}} fields.append("backgroundColor") # Padding if padding_top is not None: table_cell_style["paddingTop"] = {"magnitude": padding_top, "unit": "PT"} fields.append("paddingTop") if padding_bottom is not None: table_cell_style["paddingBottom"] = {"magnitude": padding_bottom, "unit": "PT"} fields.append("paddingBottom") if padding_left is not None: table_cell_style["paddingLeft"] = {"magnitude": padding_left, "unit": "PT"} fields.append("paddingLeft") if padding_right is not None: table_cell_style["paddingRight"] = {"magnitude": padding_right, "unit": "PT"} fields.append("paddingRight") # Borders if border_top_color and border_top_width is not None: rgb = hex_to_rgb_color(border_top_color) if rgb: table_cell_style["borderTop"] = { "color": {"rgbColor": rgb}, "width": {"magnitude": border_top_width, "unit": "PT"}, "dashStyle": "SOLID", } fields.append("borderTop") if border_bottom_color and border_bottom_width is not None: rgb = hex_to_rgb_color(border_bottom_color) if rgb: table_cell_style["borderBottom"] = { "color": {"rgbColor": rgb}, "width": {"magnitude": border_bottom_width, "unit": "PT"}, "dashStyle": "SOLID", } fields.append("borderBottom") if border_left_color and border_left_width is not None: rgb = hex_to_rgb_color(border_left_color) if rgb: table_cell_style["borderLeft"] = { "color": {"rgbColor": rgb}, "width": {"magnitude": border_left_width, "unit": "PT"}, "dashStyle": "SOLID", } fields.append("borderLeft") if border_right_color and border_right_width is not None: rgb = hex_to_rgb_color(border_right_color) if rgb: table_cell_style["borderRight"] = { "color": {"rgbColor": rgb}, "width": {"magnitude": border_right_width, "unit": "PT"}, "dashStyle": "SOLID", } fields.append("borderRight") # Return None if no styles provided if not fields: return None return { "updateTableCellStyle": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": row_index, "columnIndex": column_index, }, "tableCellStyle": table_cell_style, "fields": ",".join(fields), } } def build_merge_table_cells_request( table_start_index: int, start_row: int, start_column: int, row_span: int, column_span: int, ) -> dict: """ Build a mergeTableCells request. Args: table_start_index: Index where the table starts start_row: Starting row index (0-based) start_column: Starting column index (0-based) row_span: Number of rows to merge column_span: Number of columns to merge Returns: Request dictionary for Google Docs API """ return { "mergeTableCells": { "tableRange": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": start_row, "columnIndex": start_column, }, "rowSpan": row_span, "columnSpan": column_span, } } } def build_unmerge_table_cells_request( table_start_index: int, row_index: int, column_index: int ) -> dict: """ Build an unmergeTableCells request. Args: table_start_index: Index where the table starts row_index: Row index (0-based) column_index: Column index (0-based) Returns: Request dictionary for Google Docs API """ return { "unmergeTableCells": { "tableCellLocation": { "tableStartLocation": {"index": table_start_index}, "rowIndex": row_index, "columnIndex": column_index, } } } # --- Named Range Helpers --- def build_create_named_range_request( name: str, start_index: int, end_index: int, tab_id: str | None = None ) -> dict: """ Build a createNamedRange request. Args: name: Name for the range start_index: Starting index (inclusive, 1-based) end_index: Ending index (exclusive) tab_id: Optional tab ID Returns: Request dictionary for Google Docs API """ request: dict[str, Any] = { "createNamedRange": { "name": name, "range": {"startIndex": start_index, "endIndex": end_index}, } } if tab_id: request["createNamedRange"]["range"]["tabId"] = tab_id return request def build_delete_named_range_request(named_range_id: str) -> dict: """ Build a deleteNamedRange request. Args: named_range_id: ID of the named range to delete Returns: Request dictionary for Google Docs API """ return {"deleteNamedRange": {"namedRangeId": named_range_id}} # --- Content Element Helpers --- def build_insert_footnote_request(index: int, footnote_text: str) -> dict: """ Build an insertFootnote request. Args: index: Index where to insert footnote (1-based) footnote_text: Text content of the footnote Returns: Request dictionary for Google Docs API """ return { "insertInlineImage": { "location": {"index": index}, "footnoteText": footnote_text, } } def build_insert_table_of_contents_request(index: int) -> dict: """ Build an insertTableOfContents request. Args: index: Index where to insert TOC (1-based) Returns: Request dictionary for Google Docs API """ return {"insertTableOfContents": {"location": {"index": index}}} def build_insert_horizontal_rule_request(index: int) -> dict: """ Build an insertHorizontalRule request. Args: index: Index where to insert rule (1-based) Returns: Request dictionary for Google Docs API """ return {"insertHorizontalRule": {"location": {"index": index}}} def build_insert_section_break_request(index: int, section_type: str = "CONTINUOUS") -> dict: """ Build an insertSectionBreak request. Args: index: Index where to insert section break (1-based) section_type: Type of section break (CONTINUOUS, NEXT_PAGE, etc.) Returns: Request dictionary for Google Docs API """ return { "insertSectionBreak": { "location": {"index": index}, "sectionType": section_type, } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/nickweedon/google-docs-mcp-docker'

If you have feedback or need assistance with the MCP directory API, please join our Discord server