import type { FastMCP } from 'fastmcp'; import { UserError } from 'fastmcp'; import { z } from 'zod'; import { docs_v1 } from 'googleapis'; import { getDocsClient } from '../../clients.js'; import { DocumentIdParameter } from '../../types.js'; import * as GDocsHelpers from '../../googleDocsApiHelpers.js'; import { buildInsertTableWithDataRequests } from './insertTableWithData.js'; import { extractDocumentTables, extractTableSnapshot } from './structureHelpers.js'; const CLONE_TABLE_SOURCE_FIELDS = 'body(content(startIndex,endIndex,table(rows,columns,tableStyle(tableColumnProperties(width,widthType)),tableRows(startIndex,endIndex,tableRowStyle(minRowHeight,preventOverflow,tableHeader),tableCells(startIndex,endIndex,tableCellStyle(backgroundColor,borderTop(color,width,dashStyle),borderBottom(color,width,dashStyle),borderLeft(color,width,dashStyle),borderRight(color,width,dashStyle),contentAlignment,paddingTop,paddingBottom,paddingLeft,paddingRight,rowSpan,columnSpan),content(paragraph(elements(startIndex,endIndex,textRun(content,textStyle(bold))))))))),tabs(tabProperties(tabId,title),documentTab(body(content(startIndex,endIndex,table(rows,columns,tableStyle(tableColumnProperties(width,widthType)),tableRows(startIndex,endIndex,tableRowStyle(minRowHeight,preventOverflow,tableHeader),tableCells(startIndex,endIndex,tableCellStyle(backgroundColor,borderTop(color,width,dashStyle),borderBottom(color,width,dashStyle),borderLeft(color,width,dashStyle),borderRight(color,width,dashStyle),contentAlignment,paddingTop,paddingBottom,paddingLeft,paddingRight,rowSpan,columnSpan),content(paragraph(elements(startIndex,endIndex,textRun(content,textStyle(bold))))))))))))'; const CloneTableParameters = DocumentIdParameter.extend({ sourceDocumentId: z.string().min(1).describe('Document ID containing the source table template.'), sourceTableId: z.string().min(1).describe('Source MCP table ID from listDocumentTables.'), index: z .number() .int() .min(1) .describe( '1-based character index in the target document where the cloned table should be inserted.' ), sourceTabId: z.string().optional().describe('Optional tab ID for the source document table.'), targetTabId: z .string() .optional() .describe('Optional tab ID for the target document insertion point.'), copyColumnWidths: z .boolean() .optional() .default(true) .describe('Copy fixed column widths from the source table.'), copyRowStyles: z .boolean() .optional() .default(true) .describe('Copy row min height and overflow settings from the source table.'), copyCellStyles: z .boolean() .optional() .default(true) .describe('Copy cell-level formatting such as background, padding, alignment, and borders.'), copyPinnedHeaderRows: z .boolean() .optional() .default(true) .describe('Copy pinned header rows from the source table when present.'), copyHeaderBold: z .boolean() .optional() .default(true) .describe('Apply bold text to cloned cells whose source cell text was bold.'), }); export function register(server: FastMCP) { server.addTool({ name: 'cloneTable', description: 'Clones a source Google Docs table into a target document, preserving text, column widths, row styles, cell styles, and pinned header rows where supported.', parameters: CloneTableParameters, execute: async (args, { log }) => { const docs = await getDocsClient(); log.info( `Cloning table ${args.sourceTableId} from ${args.sourceDocumentId} into ${args.documentId} at index ${args.index}` ); try { const sourceRes = await docs.documents.get({ documentId: args.sourceDocumentId, includeTabsContent: true, fields: CLONE_TABLE_SOURCE_FIELDS, }); const snapshot = extractTableSnapshot(sourceRes.data, args.sourceTableId, args.sourceTabId); if (!snapshot) { throw new UserError( `Source table "${args.sourceTableId}" was not found in source document ${args.sourceDocumentId}.` ); } if (snapshot.rowCount === 0 || snapshot.columnCount === 0) { throw new UserError( `Source table "${args.sourceTableId}" is empty and cannot be cloned.` ); } if (args.targetTabId) { const targetInfo = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, fields: 'tabs(tabProperties,documentTab(body))', }); const targetTab = GDocsHelpers.findTabById(targetInfo.data, args.targetTabId); if (!targetTab) throw new UserError(`Target tab "${args.targetTabId}" not found in document.`); if (!targetTab.documentTab) { throw new UserError(`Target tab "${args.targetTabId}" does not have document content.`); } } const insertRequests = buildInsertTableWithDataRequests( snapshot.data, args.index, false, args.targetTabId ); await GDocsHelpers.executeBatchUpdateWithSplitting( docs, args.documentId, insertRequests, log ); const targetRes = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, fields: 'body(content(startIndex,endIndex,table(rows,columns,tableRows(tableCells(startIndex,endIndex,content(paragraph(elements(startIndex,endIndex,textRun(content))))))))),tabs(tabProperties(tabId,title),documentTab(body(content(startIndex,endIndex,table(rows,columns,tableRows(tableCells(startIndex,endIndex,content(paragraph(elements(startIndex,endIndex,textRun(content)))))))))))', }); const targetTable = extractDocumentTables(targetRes.data, args.targetTabId) .filter( (table) => table.startIndex != null && table.startIndex >= args.index && table.rowCount === snapshot.rowCount && table.columnCount === snapshot.columnCount ) .sort( (a, b) => (a.startIndex ?? Number.MAX_SAFE_INTEGER) - (b.startIndex ?? Number.MAX_SAFE_INTEGER) )[0]; if (!targetTable || targetTable.startIndex == null) { throw new UserError( 'Cloned target table was inserted, but could not be re-located safely for style copying.' ); } const styleRequests: docs_v1.Schema$Request[] = []; if (args.copyColumnWidths) { for (const columnStyle of snapshot.columnStyles) { if (columnStyle.widthType !== 'FIXED_WIDTH' || !columnStyle.widthPt) continue; styleRequests.push( GDocsHelpers.buildTableColumnWidthRequest( targetTable.startIndex, [columnStyle.columnIndex], columnStyle.widthPt, args.targetTabId ) ); } } if (args.copyRowStyles) { for (const rowStyle of snapshot.rowStyles) { const request = GDocsHelpers.buildTableRowStyleRequest( targetTable.startIndex, [rowStyle.rowIndex], rowStyle.minRowHeightPt, rowStyle.preventOverflow, args.targetTabId ); if (request) styleRequests.push(request); } } if (args.copyPinnedHeaderRows && snapshot.pinnedHeaderRowsCount > 0) { styleRequests.push( GDocsHelpers.buildPinTableHeaderRowsRequest( targetTable.startIndex, snapshot.pinnedHeaderRowsCount, args.targetTabId ) ); } if (args.copyCellStyles) { for (const cellStyle of snapshot.cellStyles) { const requestInfo = GDocsHelpers.buildTableCellStyleRequest( targetTable.startIndex, cellStyle.rowIndex, cellStyle.columnIndex, { backgroundColor: cellStyle.backgroundColor, contentAlignment: cellStyle.contentAlignment ?? undefined, paddingTopPt: cellStyle.paddingTopPt, paddingBottomPt: cellStyle.paddingBottomPt, paddingLeftPt: cellStyle.paddingLeftPt, paddingRightPt: cellStyle.paddingRightPt, borderTop: cellStyle.borderTop, borderBottom: cellStyle.borderBottom, borderLeft: cellStyle.borderLeft, borderRight: cellStyle.borderRight, }, args.targetTabId ); if (requestInfo) styleRequests.push(requestInfo.request); } } if (args.copyHeaderBold) { for (const cellStyle of snapshot.cellStyles) { if (!cellStyle.hasBoldText) continue; const targetCell = targetTable.cells.find( (cell) => cell.rowIndex === cellStyle.rowIndex && cell.columnIndex === cellStyle.columnIndex ); if (!targetCell?.contentStartIndex) continue; const targetText = snapshot.data[cellStyle.rowIndex]?.[cellStyle.columnIndex] ?? ''; if (!targetText) continue; const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest( targetCell.contentStartIndex, targetCell.contentStartIndex + targetText.length, { bold: true }, args.targetTabId ); if (requestInfo) styleRequests.push(requestInfo.request); } } if (styleRequests.length > 0) { await GDocsHelpers.executeBatchUpdateWithSplitting( docs, args.documentId, styleRequests, log ); } return `Successfully cloned ${args.sourceTableId} into ${args.documentId} at index ${args.index}.`; } catch (error: any) { log.error( `Error cloning table ${args.sourceTableId} from ${args.sourceDocumentId}: ${error.message || error}` ); if (error instanceof UserError) throw error; throw new UserError(`Failed to clone table: ${error.message || 'Unknown error'}`); } }, }); }