google-docs-mcp / src /tools /docs /cloneTable.ts
iFightDucks's picture
Initial HF Space deploy: a-bonus/google-docs-mcp with HF metadata
7dc28be
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'}`);
}
},
});
}