import type { FastMCP } from 'fastmcp'; import { UserError } from 'fastmcp'; import { z } from 'zod'; import { getDocsClient } from '../../clients.js'; import { DocumentIdParameter, MarkdownConversionError } from '../../types.js'; import * as GDocsHelpers from '../../googleDocsApiHelpers.js'; import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js'; import { TAB_BODY_RANGE_FIELDS } from '../docs/tabFieldMasks.js'; export function register(server: FastMCP) { server.addTool({ name: 'replaceDocumentWithMarkdown', description: "Replaces the entire document body with content parsed from markdown. Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes.", parameters: DocumentIdParameter.extend({ markdown: z .string() .min(1) .max(500000) .describe('The markdown content to apply to the document.'), preserveTitle: z .boolean() .optional() .default(false) .describe('If true, preserves the first heading/title and replaces content after it.'), tabId: z .string() .optional() .describe( 'The ID of the specific tab to replace content in. If not specified, replaces content in the first tab.' ), firstHeadingAsTitle: z .boolean() .optional() .default(true) .describe( 'If true (default), the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title. Set to false if the first H1 should remain a Heading 1.' ), }), execute: async (args, { log }) => { const docs = await getDocsClient(); log.info( `Replacing doc ${args.documentId} with markdown (${args.markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}` ); try { // 1. Get document structure const doc = await docs.documents.get({ documentId: args.documentId, includeTabsContent: !!args.tabId, suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS', fields: args.tabId ? TAB_BODY_RANGE_FIELDS : 'body(content(startIndex,endIndex))', }); // 2. Calculate replacement range let startIndex = 1; let bodyContent: any; if (args.tabId) { const targetTab = GDocsHelpers.findTabById(doc.data, args.tabId); if (!targetTab) { throw new UserError(`Tab with ID "${args.tabId}" not found in document.`); } if (!targetTab.documentTab) { throw new UserError( `Tab "${args.tabId}" does not have content (may not be a document tab).` ); } bodyContent = targetTab.documentTab.body?.content; } else { bodyContent = doc.data.body?.content; } if (!bodyContent) { throw new UserError('No content found in document/tab'); } let endIndex = bodyContent[bodyContent.length - 1].endIndex! - 1; if (args.preserveTitle) { // Find first content element that's a heading or paragraph for (const element of bodyContent) { if (element.paragraph && element.endIndex) { startIndex = element.endIndex; break; } } } // 3. Delete existing content if (endIndex > startIndex) { const deleteRange: any = { startIndex, endIndex }; if (args.tabId) { deleteRange.tabId = args.tabId; } log.info(`Deleting content from index ${startIndex} to ${endIndex}`); await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [ { deleteContentRange: { range: deleteRange }, }, ]); log.info(`Delete complete.`); } // 4. Clean the surviving trailing paragraph. // deleteContentRange always leaves one trailing paragraph that cannot // be deleted. If it has bullet list membership or text formatting from // the old content, all subsequently inserted text inherits those // properties, corrupting the new document. We strip both bullets and // text styles from the survivor before inserting. { // Re-read to get the survivor's endIndex (always a short document now) const docAfterDelete = await docs.documents.get({ documentId: args.documentId, includeTabsContent: !!args.tabId, suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS', fields: args.tabId ? TAB_BODY_RANGE_FIELDS : 'body(content(startIndex,endIndex))', }); let survivorContent: any; if (args.tabId) { const tab = GDocsHelpers.findTabById(docAfterDelete.data, args.tabId); survivorContent = tab?.documentTab?.body?.content; } else { survivorContent = docAfterDelete.data.body?.content; } const survivorEnd = survivorContent ? survivorContent[survivorContent.length - 1].endIndex! : startIndex + 1; const survivorRange: any = { startIndex, endIndex: survivorEnd }; if (args.tabId) { survivorRange.tabId = args.tabId; } const cleanupRequests: any[] = [ { deleteParagraphBullets: { range: survivorRange } }, { updateTextStyle: { range: survivorRange, textStyle: { underline: false, bold: false, italic: false, strikethrough: false, foregroundColor: {}, backgroundColor: {}, }, fields: 'underline,bold,italic,strikethrough,foregroundColor,backgroundColor', }, }, ]; try { await GDocsHelpers.executeBatchUpdate(docs, args.documentId, cleanupRequests); log.info( `Cleaned surviving paragraph (bullets + text style) at range ${startIndex}-${survivorEnd}` ); } catch (e: any) { log.info(`Survivor cleanup skipped: ${e.message}`); } } // 5. Convert markdown and insert (indices calculated for empty document) log.info( `Inserting markdown starting at index ${startIndex} (after delete, document should be empty)` ); const result = await insertMarkdown(docs, args.documentId, args.markdown, { startIndex, tabId: args.tabId, firstHeadingAsTitle: args.firstHeadingAsTitle, }); const debugSummary = formatInsertResult(result); log.info(debugSummary); return `Successfully replaced document content with ${args.markdown.length} characters of markdown.\n\n${debugSummary}`; } catch (error: any) { log.error(`Error replacing document with markdown: ${error.message}`); if (error instanceof UserError || error instanceof MarkdownConversionError) { throw error; } throw new UserError(`Failed to apply markdown: ${error.message || 'Unknown error'}`); } }, }); }