google-docs-mcp / src /tools /docs /modifyText.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, 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'}`);
}
},
});
}