google-tools-mcp 1.0.16 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/googleDocsApiHelpers.js +220 -108
  2. package/dist/markdown-transformer/index.js +23 -3
  3. package/dist/markdown-transformer/markdownToDocs.js +25 -0
  4. package/dist/readTracker.js +84 -0
  5. package/dist/tools/docs/addTab.js +1 -0
  6. package/dist/tools/docs/appendToGoogleDoc.js +24 -4
  7. package/dist/tools/docs/comments/addComment.js +2 -1
  8. package/dist/tools/docs/comments/deleteComment.js +2 -1
  9. package/dist/tools/docs/comments/replyToComment.js +2 -1
  10. package/dist/tools/docs/comments/resolveComment.js +3 -2
  11. package/dist/tools/docs/deleteRange.js +5 -1
  12. package/dist/tools/docs/findAndReplace.js +5 -1
  13. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -3
  14. package/dist/tools/docs/insertImage.js +4 -2
  15. package/dist/tools/docs/insertPageBreak.js +2 -1
  16. package/dist/tools/docs/insertTable.js +2 -1
  17. package/dist/tools/docs/insertTableWithData.js +2 -1
  18. package/dist/tools/docs/modifyText.js +27 -11
  19. package/dist/tools/docs/modifyText.test.js +90 -0
  20. package/dist/tools/docs/readGoogleDoc.js +2 -0
  21. package/dist/tools/docs/renameTab.js +2 -1
  22. package/dist/tools/drive/deleteFile.js +2 -0
  23. package/dist/tools/drive/index.js +2 -0
  24. package/dist/tools/drive/moveFile.js +3 -2
  25. package/dist/tools/drive/uploadFile.js +111 -0
  26. package/dist/tools/extras/index.js +2 -0
  27. package/dist/tools/extras/readDriveFile.js +206 -0
  28. package/dist/tools/extras/readFile.js +2 -0
  29. package/dist/tools/forms/batchUpdateForm.js +1 -0
  30. package/dist/tools/forms/setPublishSettings.js +1 -0
  31. package/dist/tools/index.js +117 -11
  32. package/dist/tools/sheets/addConditionalFormatting.js +2 -1
  33. package/dist/tools/sheets/addSpreadsheetSheet.js +2 -1
  34. package/dist/tools/sheets/appendSpreadsheetRows.js +2 -1
  35. package/dist/tools/sheets/appendTableRows.js +1 -0
  36. package/dist/tools/sheets/autoResizeColumns.js +2 -1
  37. package/dist/tools/sheets/batchWrite.js +4 -1
  38. package/dist/tools/sheets/clearSpreadsheetRange.js +4 -1
  39. package/dist/tools/sheets/copyFormatting.js +2 -1
  40. package/dist/tools/sheets/createTable.js +1 -0
  41. package/dist/tools/sheets/deleteChart.js +2 -1
  42. package/dist/tools/sheets/deleteSheet.js +2 -1
  43. package/dist/tools/sheets/deleteTable.js +1 -0
  44. package/dist/tools/sheets/duplicateSheet.js +2 -1
  45. package/dist/tools/sheets/formatCells.js +2 -1
  46. package/dist/tools/sheets/freezeRowsAndColumns.js +2 -1
  47. package/dist/tools/sheets/getSpreadsheetInfo.js +2 -0
  48. package/dist/tools/sheets/groupRows.js +2 -1
  49. package/dist/tools/sheets/insertChart.js +2 -1
  50. package/dist/tools/sheets/readSpreadsheet.js +2 -0
  51. package/dist/tools/sheets/renameSheet.js +2 -1
  52. package/dist/tools/sheets/setColumnWidths.js +2 -1
  53. package/dist/tools/sheets/setDropdownValidation.js +3 -2
  54. package/dist/tools/sheets/ungroupAllRows.js +2 -1
  55. package/dist/tools/sheets/updateTableRange.js +1 -0
  56. package/dist/tools/sheets/writeSpreadsheet.js +4 -1
  57. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +27 -5
  58. package/dist/tools/utils/replaceDocumentWithMarkdown.js +28 -5
  59. package/dist/types.js +1 -2
  60. package/package.json +10 -2
@@ -160,127 +160,239 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
160
160
  }
161
161
  // --- Text Finding Helper ---
162
162
  // This improved version is more robust in handling various text structure scenarios
163
- export async function findTextRange(docs, documentId, textToFind, instance = 1, tabId) {
164
- try {
165
- // Request more detailed information about the document structure
166
- // When tabId is specified, we need to use includeTabsContent to access tab-specific content
167
- const needsTabsContent = !!tabId;
168
- const res = await docs.documents.get({
169
- documentId,
170
- ...(needsTabsContent && { includeTabsContent: true }),
171
- // Request more fields to handle various container types (not just paragraphs)
172
- fields: needsTabsContent
173
- ? 'tabs(tabProperties(tabId),documentTab(body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))))'
174
- : 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
175
- });
176
- // Get body content from the correct tab or default
177
- let bodyContent;
178
- if (tabId) {
179
- const targetTab = findTabById(res.data, tabId);
180
- if (!targetTab) {
181
- throw new UserError(`Tab with ID "${tabId}" not found in document.`);
163
+ /**
164
+ * Fetches document content and builds a flat text representation with segment mappings.
165
+ * Shared by findTextRange and other text-search utilities.
166
+ */
167
+ async function getDocumentTextAndSegments(docs, documentId, tabId) {
168
+ const needsTabsContent = !!tabId;
169
+ const res = await docs.documents.get({
170
+ documentId,
171
+ ...(needsTabsContent && { includeTabsContent: true }),
172
+ fields: needsTabsContent
173
+ ? 'tabs(tabProperties(tabId),documentTab(body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))))'
174
+ : 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
175
+ });
176
+ let bodyContent;
177
+ if (tabId) {
178
+ const targetTab = findTabById(res.data, tabId);
179
+ if (!targetTab) {
180
+ throw new UserError(`Tab with ID "${tabId}" not found in document.`);
181
+ }
182
+ if (!targetTab.documentTab?.body?.content) {
183
+ throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
184
+ }
185
+ bodyContent = targetTab.documentTab.body.content;
186
+ }
187
+ else {
188
+ bodyContent = res.data.body?.content;
189
+ }
190
+ if (!bodyContent) {
191
+ return null;
192
+ }
193
+ let fullText = '';
194
+ const segments = [];
195
+ const collectTextFromContent = (content) => {
196
+ content.forEach((element) => {
197
+ if (element.paragraph?.elements) {
198
+ element.paragraph.elements.forEach((pe) => {
199
+ if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
200
+ const content = pe.textRun.content;
201
+ fullText += content;
202
+ segments.push({
203
+ text: content,
204
+ start: pe.startIndex,
205
+ end: pe.endIndex,
206
+ });
207
+ }
208
+ });
182
209
  }
183
- if (!targetTab.documentTab?.body?.content) {
184
- throw new UserError(`Tab "${tabId}" does not have content (may not be a document tab).`);
210
+ if (element.table && element.table.tableRows) {
211
+ element.table.tableRows.forEach((row) => {
212
+ if (row.tableCells) {
213
+ row.tableCells.forEach((cell) => {
214
+ if (cell.content) {
215
+ collectTextFromContent(cell.content);
216
+ }
217
+ });
218
+ }
219
+ });
185
220
  }
186
- bodyContent = targetTab.documentTab.body.content;
221
+ });
222
+ };
223
+ collectTextFromContent(bodyContent);
224
+ segments.sort((a, b) => a.start - b.start);
225
+ return { fullText, segments };
226
+ }
227
+ /**
228
+ * Maps a position in the concatenated fullText back to the actual document index.
229
+ */
230
+ function mapFullTextPositionToDocIndex(posInFullText, segments) {
231
+ let currentPos = 0;
232
+ for (const seg of segments) {
233
+ const segStart = currentPos;
234
+ const segEnd = segStart + seg.text.length;
235
+ if (posInFullText >= segStart && posInFullText < segEnd) {
236
+ return seg.start + (posInFullText - segStart);
187
237
  }
188
- else {
189
- bodyContent = res.data.body?.content;
238
+ // Also handle the position being exactly at segEnd (for end indices)
239
+ if (posInFullText === segEnd) {
240
+ return seg.start + seg.text.length;
190
241
  }
191
- if (!bodyContent) {
242
+ currentPos = segEnd;
243
+ }
244
+ return -1;
245
+ }
246
+ /**
247
+ * Character-level Unicode normalization rules.
248
+ * Maps typographic characters to their ASCII equivalents.
249
+ */
250
+ const NORMALIZE_MAP = {
251
+ '\u2018': "'", '\u2019': "'", '\u201A': "'", '\u201B': "'", '\u2032': "'", '\u2035': "'", // smart single quotes
252
+ '\u201C': '"', '\u201D': '"', '\u201E': '"', '\u201F': '"', '\u2033': '"', '\u2036': '"', // smart double quotes
253
+ '\u2014': '--', // em dash
254
+ '\u2013': '-', // en dash
255
+ '\u2026': '...', // ellipsis
256
+ '\u00A0': ' ', // non-breaking space
257
+ '\u000B': '\n', // vertical tab (Google Docs soft return)
258
+ };
259
+ /**
260
+ * Normalizes a string for search, returning both the normalized text
261
+ * and a position map from normalized-index → original-index.
262
+ */
263
+ function normalizeWithPositionMap(text) {
264
+ let normalized = '';
265
+ const posMap = []; // posMap[normalizedIdx] = originalIdx
266
+ for (let i = 0; i < text.length; i++) {
267
+ const ch = text[i];
268
+ const replacement = NORMALIZE_MAP[ch];
269
+ if (replacement) {
270
+ for (let j = 0; j < replacement.length; j++) {
271
+ posMap.push(i);
272
+ normalized += replacement[j];
273
+ }
274
+ } else {
275
+ posMap.push(i);
276
+ normalized += ch;
277
+ }
278
+ }
279
+ // Sentinel for end-of-string mapping
280
+ posMap.push(text.length);
281
+ return { normalized, posMap };
282
+ }
283
+ /**
284
+ * Simple normalization without position map (for normalizing the search query).
285
+ */
286
+ function normalizeForSearch(text) {
287
+ let result = '';
288
+ for (let i = 0; i < text.length; i++) {
289
+ const replacement = NORMALIZE_MAP[text[i]];
290
+ result += replacement ?? text[i];
291
+ }
292
+ return result;
293
+ }
294
+ /**
295
+ * Finds all occurrences of textToFind in the document and returns them with
296
+ * surrounding context and mapped document indices.
297
+ */
298
+ function findAllOccurrences(fullText, segments, textToFind) {
299
+ const CONTEXT_CHARS = 30;
300
+ const occurrences = [];
301
+ let searchFrom = 0;
302
+ while (true) {
303
+ const idx = fullText.indexOf(textToFind, searchFrom);
304
+ if (idx === -1)
305
+ break;
306
+ const docStart = mapFullTextPositionToDocIndex(idx, segments);
307
+ const docEnd = mapFullTextPositionToDocIndex(idx + textToFind.length, segments);
308
+ // Extract surrounding context
309
+ const contextStart = Math.max(0, idx - CONTEXT_CHARS);
310
+ const contextEnd = Math.min(fullText.length, idx + textToFind.length + CONTEXT_CHARS);
311
+ const before = fullText.slice(contextStart, idx).replace(/\n/g, '\\n');
312
+ const match = fullText.slice(idx, idx + textToFind.length).replace(/\n/g, '\\n');
313
+ const after = fullText.slice(idx + textToFind.length, contextEnd).replace(/\n/g, '\\n');
314
+ const context = `${contextStart > 0 ? '...' : ''}${before}[${match}]${after}${contextEnd < fullText.length ? '...' : ''}`;
315
+ occurrences.push({
316
+ instance: occurrences.length + 1,
317
+ startIndex: docStart,
318
+ endIndex: docEnd,
319
+ context,
320
+ });
321
+ searchFrom = idx + 1;
322
+ }
323
+ return occurrences;
324
+ }
325
+ export async function findTextRange(docs, documentId, textToFind, instance, tabId) {
326
+ try {
327
+ const result = await getDocumentTextAndSegments(docs, documentId, tabId);
328
+ if (!result) {
192
329
  logger.warn(`No content found in document ${documentId}${tabId ? ` (tab: ${tabId})` : ''}`);
193
330
  return null;
194
331
  }
195
- // More robust text collection and index tracking
196
- let fullText = '';
197
- const segments = [];
198
- // Process all content elements, including structural ones
199
- const collectTextFromContent = (content) => {
200
- content.forEach((element) => {
201
- // Handle paragraph elements
202
- if (element.paragraph?.elements) {
203
- element.paragraph.elements.forEach((pe) => {
204
- if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
205
- const content = pe.textRun.content;
206
- fullText += content;
207
- segments.push({
208
- text: content,
209
- start: pe.startIndex,
210
- end: pe.endIndex,
211
- });
212
- }
213
- });
214
- }
215
- // Handle table elements - this is simplified and might need expansion
216
- if (element.table && element.table.tableRows) {
217
- element.table.tableRows.forEach((row) => {
218
- if (row.tableCells) {
219
- row.tableCells.forEach((cell) => {
220
- if (cell.content) {
221
- collectTextFromContent(cell.content);
222
- }
223
- });
224
- }
225
- });
226
- }
227
- // Add handling for other structural elements as needed
228
- });
229
- };
230
- collectTextFromContent(bodyContent);
231
- // Sort segments by starting position to ensure correct ordering
232
- segments.sort((a, b) => a.start - b.start);
332
+ const { fullText, segments } = result;
233
333
  logger.debug(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
234
- // Find the specified instance of the text
235
- let startIndex = -1;
236
- let endIndex = -1;
237
- let foundCount = 0;
238
- let searchStartIndex = 0;
239
- while (foundCount < instance) {
240
- const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
241
- if (currentIndex === -1) {
242
- logger.debug(`Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})`);
243
- break;
244
- }
245
- foundCount++;
246
- logger.debug(`Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text`);
247
- if (foundCount === instance) {
248
- const targetStartInFullText = currentIndex;
249
- const targetEndInFullText = currentIndex + textToFind.length;
250
- let currentPosInFullText = 0;
251
- logger.debug(`Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}`);
252
- for (const seg of segments) {
253
- const segStartInFullText = currentPosInFullText;
254
- const segTextLength = seg.text.length;
255
- const segEndInFullText = segStartInFullText + segTextLength;
256
- // Map from reconstructed text position to actual document indices
257
- if (startIndex === -1 &&
258
- targetStartInFullText >= segStartInFullText &&
259
- targetStartInFullText < segEndInFullText) {
260
- startIndex = seg.start + (targetStartInFullText - segStartInFullText);
261
- logger.debug(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`);
262
- }
263
- if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
264
- endIndex = seg.start + (targetEndInFullText - segStartInFullText);
265
- logger.debug(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
266
- break;
267
- }
268
- currentPosInFullText = segEndInFullText;
269
- }
270
- if (startIndex === -1 || endIndex === -1) {
271
- logger.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
272
- return { startIndex, endIndex };
334
+ let allOccurrences = findAllOccurrences(fullText, segments, textToFind);
335
+ // Fallback: try normalized matching if exact match fails (issue #11)
336
+ if (allOccurrences.length === 0) {
337
+ const normalizedSearch = normalizeForSearch(textToFind);
338
+ const { normalized: normalizedFull, posMap } = normalizeWithPositionMap(fullText);
339
+ if (normalizedSearch !== textToFind || normalizedFull !== fullText) {
340
+ logger.debug(`Exact match failed, trying normalized match`);
341
+ // Find in normalized text, then map positions back to original
342
+ const CONTEXT_CHARS = 30;
343
+ let searchFrom = 0;
344
+ while (true) {
345
+ const idx = normalizedFull.indexOf(normalizedSearch, searchFrom);
346
+ if (idx === -1) break;
347
+ // Map normalized positions back to original fullText positions
348
+ const origStart = posMap[idx];
349
+ const origEnd = posMap[idx + normalizedSearch.length];
350
+ const docStart = mapFullTextPositionToDocIndex(origStart, segments);
351
+ const docEnd = mapFullTextPositionToDocIndex(origEnd, segments);
352
+ const contextStart = Math.max(0, origStart - CONTEXT_CHARS);
353
+ const contextEnd = Math.min(fullText.length, origEnd + CONTEXT_CHARS);
354
+ const before = fullText.slice(contextStart, origStart).replace(/\n/g, '\\n');
355
+ const match = fullText.slice(origStart, origEnd).replace(/\n/g, '\\n');
356
+ const after = fullText.slice(origEnd, contextEnd).replace(/\n/g, '\\n');
357
+ const context = `${contextStart > 0 ? '...' : ''}${before}[${match}]${after}${contextEnd < fullText.length ? '...' : ''}`;
358
+ allOccurrences.push({
359
+ instance: allOccurrences.length + 1,
360
+ startIndex: docStart,
361
+ endIndex: docEnd,
362
+ context,
363
+ });
364
+ searchFrom = idx + 1;
273
365
  }
274
- logger.debug(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
275
- return { startIndex, endIndex };
276
366
  }
277
- // Prepare for next search iteration
278
- searchStartIndex = currentIndex + 1;
279
367
  }
280
- logger.warn(`Could not find instance ${instance} of text "${textToFind}" in document ${documentId}`);
281
- return null; // Instance not found or mapping failed for all attempts
368
+ if (allOccurrences.length === 0) {
369
+ logger.warn(`Text "${textToFind}" not found in document ${documentId}`);
370
+ return null;
371
+ }
372
+ // If instance is not specified and there are multiple matches, return all of them
373
+ // so the caller can disambiguate
374
+ if (instance === undefined && allOccurrences.length > 1) {
375
+ const listing = allOccurrences.map((o) => ` ${o.instance}. index ${o.startIndex}-${o.endIndex}: ${o.context}`).join('\n');
376
+ throw new UserError(`Found ${allOccurrences.length} instances of "${textToFind}". ` +
377
+ `Specify matchInstance to target the correct one:\n${listing}`);
378
+ }
379
+ // Use instance 1 if not specified (single match case)
380
+ const targetInstance = instance ?? 1;
381
+ if (targetInstance > allOccurrences.length) {
382
+ logger.warn(`Requested instance ${targetInstance} but only ${allOccurrences.length} found`);
383
+ return null;
384
+ }
385
+ const match = allOccurrences[targetInstance - 1];
386
+ if (match.startIndex === -1 || match.endIndex === -1) {
387
+ logger.warn(`Failed to map text "${textToFind}" instance ${targetInstance} to actual document indices`);
388
+ return { startIndex: match.startIndex, endIndex: match.endIndex };
389
+ }
390
+ logger.debug(`Successfully mapped "${textToFind}" instance ${targetInstance} to document range ${match.startIndex}-${match.endIndex}`);
391
+ return { startIndex: match.startIndex, endIndex: match.endIndex };
282
392
  }
283
393
  catch (error) {
394
+ if (error instanceof UserError)
395
+ throw error;
284
396
  logger.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
285
397
  if (error.code === 404)
286
398
  throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
@@ -85,10 +85,30 @@ export async function insertMarkdown(docs, documentId, markdown, options) {
85
85
  const overallStart = performance.now();
86
86
  const startIndex = options?.startIndex ?? 1;
87
87
  const tabId = options?.tabId;
88
+ // Fetch the document's default text style so we can explicitly set
89
+ // foreground color on inserted text (fixes issue #14 — text without
90
+ // explicit color shows "no color selected" in the Docs color picker).
91
+ let defaultForegroundColor;
92
+ try {
93
+ const styleRes = await docs.documents.get({
94
+ documentId,
95
+ fields: 'namedStyles',
96
+ });
97
+ const normalTextStyle = styleRes.data.namedStyles?.styles?.find(
98
+ (s) => s.namedStyleType === 'NORMAL_TEXT'
99
+ );
100
+ const fg = normalTextStyle?.textStyle?.foregroundColor?.color?.rgbColor;
101
+ if (fg) {
102
+ defaultForegroundColor = fg;
103
+ }
104
+ } catch {
105
+ // Non-fatal — if we can't read styles, proceed without explicit color
106
+ }
88
107
  const parseStart = performance.now();
89
- const conversionOptions = options?.firstHeadingAsTitle
90
- ? { firstHeadingAsTitle: true }
91
- : undefined;
108
+ const conversionOptions = {
109
+ ...(options?.firstHeadingAsTitle && { firstHeadingAsTitle: true }),
110
+ ...(defaultForegroundColor && { defaultForegroundColor }),
111
+ };
92
112
  const requests = convertMarkdownToRequests(markdown, startIndex, tabId, conversionOptions);
93
113
  const parseElapsedMs = Math.round(performance.now() - parseStart);
94
114
  // Count requests by type
@@ -70,6 +70,7 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
70
70
  const parser = createParser();
71
71
  const tokens = parser.parse(markdown, {});
72
72
  const context = {
73
+ startIndex,
73
74
  currentIndex: startIndex,
74
75
  insertRequests: [],
75
76
  formatRequests: [],
@@ -88,6 +89,7 @@ export function convertMarkdownToRequests(markdown, startIndex = 1, tabId, optio
88
89
  tabId,
89
90
  titleConsumed: false,
90
91
  firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
92
+ defaultForegroundColor: options?.defaultForegroundColor ?? null,
91
93
  };
92
94
  try {
93
95
  for (const token of tokens) {
@@ -624,6 +626,29 @@ function popFormatting(context, type) {
624
626
  }
625
627
  // --- Finalization ---
626
628
  function finalizeFormatting(context) {
629
+ // Apply the document's default foreground color to the entire inserted range
630
+ // so text has an explicit color value in Google Docs (fixes issue #14).
631
+ // This goes first so intentional colors (code blocks, links) override it.
632
+ if (context.defaultForegroundColor && context.currentIndex > context.startIndex) {
633
+ const baseRange = {
634
+ startIndex: context.startIndex,
635
+ endIndex: context.currentIndex,
636
+ };
637
+ if (context.tabId) {
638
+ baseRange.tabId = context.tabId;
639
+ }
640
+ context.formatRequests.push({
641
+ updateTextStyle: {
642
+ range: baseRange,
643
+ textStyle: {
644
+ foregroundColor: {
645
+ color: { rgbColor: context.defaultForegroundColor },
646
+ },
647
+ },
648
+ fields: 'foregroundColor',
649
+ },
650
+ });
651
+ }
627
652
  // Character-level formatting (bold, italic, strikethrough, code, links)
628
653
  for (const range of context.textRanges) {
629
654
  const rangeLocation = {
@@ -0,0 +1,84 @@
1
+ // In-memory tracker for read-before-edit guards (issue #18).
2
+ // Tracks which files have been read in this session and when.
3
+ import { UserError } from 'fastmcp';
4
+ import { getDriveClient } from './clients.js';
5
+ import { logger } from './logger.js';
6
+
7
+ // Map of fileId → { readAt: Date, modifiedTime: string (ISO) }
8
+ const readLog = new Map();
9
+
10
+ /**
11
+ * Record that a file was read. Call from all read tools.
12
+ */
13
+ export function trackRead(fileId, modifiedTime) {
14
+ readLog.set(fileId, {
15
+ readAt: new Date(),
16
+ modifiedTime: modifiedTime || null,
17
+ });
18
+ }
19
+
20
+ /**
21
+ * Guard a mutation. Call from all mutating tools before making changes.
22
+ * Throws UserError if the file hasn't been read, or if it was modified externally.
23
+ *
24
+ * @param fileId - The file/document/spreadsheet ID
25
+ * @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
26
+ */
27
+ export async function guardMutation(fileId, opts) {
28
+ const entry = readLog.get(fileId);
29
+ if (!entry) {
30
+ throw new UserError(
31
+ `This file (${fileId}) has not been read in this session. ` +
32
+ 'Read it first before making changes to ensure you have current content. ' +
33
+ 'Use readDocument, readSpreadsheet, readFile, or readDriveFile.'
34
+ );
35
+ }
36
+
37
+ // Optionally check if file was modified externally since last read
38
+ if (!opts?.skipExternalCheck) {
39
+ try {
40
+ const drive = await getDriveClient();
41
+ const res = await drive.files.get({
42
+ fileId,
43
+ fields: 'modifiedTime',
44
+ supportsAllDrives: true,
45
+ });
46
+ const currentModifiedTime = res.data.modifiedTime;
47
+ if (entry.modifiedTime && currentModifiedTime !== entry.modifiedTime) {
48
+ const readAt = entry.readAt.toISOString();
49
+ throw new UserError(
50
+ `This file was modified externally since you last read it ` +
51
+ `(last read: ${readAt}, last modified: ${currentModifiedTime}). ` +
52
+ 'Read the file again before editing to ensure you have current content.'
53
+ );
54
+ }
55
+ // Update modifiedTime on successful check
56
+ entry.modifiedTime = currentModifiedTime;
57
+ } catch (error) {
58
+ if (error instanceof UserError) throw error;
59
+ // If we can't check, log warning but allow the mutation
60
+ logger.warn(`Could not verify modifiedTime for ${fileId}: ${error.message}`);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Update the read tracker after a successful mutation (so subsequent mutations
67
+ * don't fail the external-change check against our own changes).
68
+ */
69
+ export function trackMutation(fileId) {
70
+ const entry = readLog.get(fileId);
71
+ if (entry) {
72
+ entry.readAt = new Date();
73
+ // Clear modifiedTime — it will be stale after our mutation.
74
+ // The next guardMutation call will fetch fresh modifiedTime.
75
+ entry.modifiedTime = null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Check if a file has been read (without throwing).
81
+ */
82
+ export function hasBeenRead(fileId) {
83
+ return readLog.has(fileId);
84
+ }
@@ -67,6 +67,7 @@ export function register(server) {
67
67
  const newTabProps = response.data.replies?.[0]?.addDocumentTab?.tabProperties;
68
68
  if (newTabProps) {
69
69
  return JSON.stringify({
70
+ url: `https://docs.google.com/document/d/${args.documentId}/edit`,
70
71
  message: `Successfully added new tab "${newTabProps.title || '(untitled)'}"`,
71
72
  tabId: newTabProps.tabId,
72
73
  title: newTabProps.title,
@@ -1,14 +1,18 @@
1
+ import * as fs from 'fs/promises';
1
2
  import { UserError } from 'fastmcp';
2
3
  import { z } from 'zod';
3
4
  import { getDocsClient } from '../../clients.js';
4
5
  import { DocumentIdParameter, NotImplementedError } from '../../types.js';
5
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
+ import { guardMutation, trackMutation } from '../../readTracker.js';
6
8
  export function register(server) {
7
9
  server.addTool({
8
10
  name: 'appendText',
9
- description: 'Appends plain text to the end of a document. For formatted content, use appendMarkdown instead.',
11
+ description: 'Appends plain text to the end of a document. For formatted content, use appendMarkdown instead. ' +
12
+ 'To edit existing content, use modifyText (single-location) or replaceDocumentWithMarkdown (section/full rewrite).',
10
13
  parameters: DocumentIdParameter.extend({
11
- text: z.string().min(1).describe('The plain text to append to the end of the document.'),
14
+ text: z.string().optional().describe('The plain text to append to the end of the document. For content longer than ~2000 characters, prefer writing to a local file first and passing filePath instead.'),
15
+ filePath: z.string().optional().describe('Path to a local text file to use as content. Takes precedence over the text parameter.'),
12
16
  addNewlineIfNeeded: z
13
17
  .boolean()
14
18
  .optional()
@@ -20,7 +24,21 @@ export function register(server) {
20
24
  .describe('The ID of the specific tab to append to. If not specified, appends to the first tab (or legacy document.body for documents without tabs).'),
21
25
  }),
22
26
  execute: async (args, { log }) => {
27
+ await guardMutation(args.documentId);
23
28
  const docs = await getDocsClient();
29
+ // Resolve text content from filePath or inline parameter
30
+ let text = args.text;
31
+ if (args.filePath) {
32
+ try {
33
+ text = await fs.readFile(args.filePath, 'utf-8');
34
+ log.info(`Read ${text.length} chars from file: ${args.filePath}`);
35
+ } catch (err) {
36
+ throw new UserError(`Failed to read file at "${args.filePath}": ${err.message}`);
37
+ }
38
+ }
39
+ if (!text || text.length === 0) {
40
+ throw new UserError('Either text or filePath must be provided with non-empty content.');
41
+ }
24
42
  log.info(`Appending to Google Doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
25
43
  try {
26
44
  // Determine if we need tabs content
@@ -54,7 +72,7 @@ export function register(server) {
54
72
  }
55
73
  }
56
74
  // Simpler approach: Always assume insertion is needed unless explicitly told not to add newline
57
- const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + args.text;
75
+ const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + text;
58
76
  if (!textToInsert)
59
77
  return 'Nothing to append.';
60
78
  const location = { index: endIndex };
@@ -65,8 +83,10 @@ export function register(server) {
65
83
  insertText: { location, text: textToInsert },
66
84
  };
67
85
  await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
86
+ trackMutation(args.documentId);
68
87
  log.info(`Successfully appended to doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
69
- return `Successfully appended text to ${args.tabId ? `tab ${args.tabId} in ` : ''}document ${args.documentId}.`;
88
+ const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
89
+ return `${docUrl}\nSuccessfully appended text to ${args.tabId ? `tab ${args.tabId} in ` : ''}document ${args.documentId}.`;
70
90
  }
71
91
  catch (error) {
72
92
  log.error(`Error appending to doc ${args.documentId}: ${error.message || error}`);
@@ -72,7 +72,8 @@ export function register(server) {
72
72
  }),
73
73
  },
74
74
  });
75
- return `Comment added successfully. Comment ID: ${response.data.id}`;
75
+ const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
76
+ return `${docUrl}\nComment added successfully. Comment ID: ${response.data.id}`;
76
77
  }
77
78
  catch (error) {
78
79
  log.error(`Error adding comment: ${error.message || error}`);
@@ -19,7 +19,8 @@ export function register(server) {
19
19
  fileId: args.documentId,
20
20
  commentId: args.commentId,
21
21
  });
22
- return `Comment ${args.commentId} has been deleted.`;
22
+ const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
23
+ return `${docUrl}\nComment ${args.commentId} has been deleted.`;
23
24
  }
24
25
  catch (error) {
25
26
  log.error(`Error deleting comment: ${error.message || error}`);
@@ -24,7 +24,8 @@ export function register(server) {
24
24
  content: args.content,
25
25
  },
26
26
  });
27
- return `Reply added successfully. Reply ID: ${response.data.id}`;
27
+ const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
28
+ return `${docUrl}\nReply added successfully. Reply ID: ${response.data.id}`;
28
29
  }
29
30
  catch (error) {
30
31
  log.error(`Error adding reply: ${error.message || error}`);
@@ -37,11 +37,12 @@ export function register(server) {
37
37
  commentId: args.commentId,
38
38
  fields: 'resolved',
39
39
  });
40
+ const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
40
41
  if (verifyComment.data.resolved) {
41
- return `Comment ${args.commentId} has been marked as resolved.`;
42
+ return `${docUrl}\nComment ${args.commentId} has been marked as resolved.`;
42
43
  }
43
44
  else {
44
- return `Attempted to resolve comment ${args.commentId}, but the resolved status may not persist in the Google Docs UI due to API limitations. The comment can be resolved manually in the Google Docs interface.`;
45
+ return `${docUrl}\nAttempted to resolve comment ${args.commentId}, but the resolved status may not persist in the Google Docs UI due to API limitations. The comment can be resolved manually in the Google Docs interface.`;
45
46
  }
46
47
  }
47
48
  catch (error) {