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, TextFindParameter, TextStyleParameters } from '../../types.js'; import type { TextStyleArgs } from '../../types.js'; import * as GDocsHelpers from '../../googleDocsApiHelpers.js'; const RangeTarget = z .object({ startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'), endIndex: z.number().int().min(1).describe('End of range (exclusive).'), }) .refine((d) => d.endIndex > d.startIndex, { message: 'endIndex must be greater than startIndex', path: ['endIndex'], }); const InsertionTarget = z.strictObject({ insertionIndex: z.number().int().min(1).describe('Index to insert at (1-based).'), }); const ModifyTextParameters = DocumentIdParameter.extend({ target: z .union([RangeTarget, TextFindParameter, InsertionTarget]) .describe('Target by range indices, text search, or insertion index.'), text: z.string().optional().describe('New text to insert or replace with.'), style: TextStyleParameters.optional().describe('Text formatting to apply.'), tabId: z .string() .optional() .describe( 'The ID of the specific tab to operate on. If not specified, operates on the first tab.' ), }) .refine((args) => args.text !== undefined || args.style !== undefined, { message: 'At least one of text or style must be provided.', }) .refine( (args) => { if ('insertionIndex' in args.target && args.text === undefined) return false; return true; }, { message: 'text is required when using insertionIndex target (no existing range to format).' } ); export interface BuildModifyTextOpts { startIndex: number; endIndex?: number; text?: string; style?: TextStyleArgs; tabId?: string; } /** * Pure, sync function that builds the array of Google Docs API requests for a * modifyText operation. Indices must already be resolved (no text-search here). */ export function buildModifyTextRequests(opts: BuildModifyTextOpts): docs_v1.Schema$Request[] { const { startIndex, endIndex, text, style, tabId } = opts; const requests: docs_v1.Schema$Request[] = []; if (!text && !style) return requests; // 1. Delete existing content (only when replacing, not insert-only) if (endIndex !== undefined && text !== undefined) { const range: any = { startIndex, endIndex }; if (tabId) range.tabId = tabId; requests.push({ deleteContentRange: { range } }); } // 2. Insert new text if (text !== undefined) { const location: any = { index: startIndex }; if (tabId) location.tabId = tabId; requests.push({ insertText: { location, text } }); } // 3. Apply formatting if (style) { const formatStart = startIndex; const formatEnd = text !== undefined ? startIndex + text.length : endIndex !== undefined ? endIndex : startIndex; if (formatEnd > formatStart) { const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest( formatStart, formatEnd, style, tabId ); if (requestInfo) { requests.push(requestInfo.request); } } } return requests; } export function register(server: FastMCP) { server.addTool({ name: 'modifyText', description: 'Combines text replacement/insertion and formatting in one atomic operation. ' + 'Can insert text at a position, replace a range or found text, apply formatting, ' + "or any combination. Use readGoogleDoc with format='json' to determine indices.", parameters: ModifyTextParameters, execute: async (args, { log }) => { const docs = await getDocsClient(); log.info( `modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` + `${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` + `${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` + `${args.tabId ? `, tab=${args.tabId}` : ''}` ); try { // Verify tab exists if specified if (args.tabId) { const docInfo = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, suggestionsViewMode: 'PREVIEW_WITHOUT_SUGGESTIONS', fields: 'tabs(tabProperties(tabId))', }); const targetTab = GDocsHelpers.findTabById(docInfo.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).` ); } } // Resolve target to numeric indices let startIndex: number; let endIndex: number | undefined; if ('insertionIndex' in args.target) { startIndex = args.target.insertionIndex; endIndex = undefined; } else if ('textToFind' in args.target) { const range = await GDocsHelpers.findTextRange( docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId ); if (!range) { throw new UserError( `Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.` ); } startIndex = range.startIndex; endIndex = range.endIndex; log.info(`Found text "${args.target.textToFind}" at range ${startIndex}-${endIndex}`); } else { startIndex = (args.target as { startIndex: number; endIndex: number }).startIndex; endIndex = (args.target as { startIndex: number; endIndex: number }).endIndex; } // Clamp to minimum 1 (index 0 is the document section break) if (startIndex < 1) startIndex = 1; const requests = buildModifyTextRequests({ startIndex, endIndex, text: args.text, style: args.style, tabId: args.tabId, }); if (requests.length === 0) { return 'No operations to perform.'; } await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests); // Build descriptive result const actions: string[] = []; if (endIndex !== undefined && args.text !== undefined) actions.push('replaced text'); else if (args.text !== undefined) actions.push('inserted text'); if (args.style) actions.push('applied formatting'); return `Successfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`; } catch (error: any) { log.error(`Error in modifyText for doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to modify text: ${error.message || 'Unknown error'}`); } }, }); }