google-tools-mcp 1.2.11 → 2.0.0

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 (97) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +555 -276
  3. package/dist/cachedToolsList.js +52 -52
  4. package/dist/googleDocsApiHelpers.js +76 -12
  5. package/dist/helpers.js +572 -252
  6. package/dist/httpAuth.js +296 -0
  7. package/dist/index.js +162 -11
  8. package/dist/logger.js +87 -87
  9. package/dist/markdown-transformer/docsToMarkdown.js +222 -21
  10. package/dist/markdown-transformer/index.js +19 -9
  11. package/dist/markdown-transformer/markdownToDocs.js +458 -28
  12. package/dist/readTracker.js +136 -17
  13. package/dist/sessionContext.js +37 -0
  14. package/dist/setup.js +187 -9
  15. package/dist/tools/calendar/getBusy.js +64 -64
  16. package/dist/tools/calendar/getEvents.js +141 -141
  17. package/dist/tools/calendar/getFree.js +225 -225
  18. package/dist/tools/calendar/index.js +19 -19
  19. package/dist/tools/calendar/listCalendars.js +38 -38
  20. package/dist/tools/calendar/listRecurringInstances.js +83 -83
  21. package/dist/tools/calendar/manageCalendar.js +121 -121
  22. package/dist/tools/calendar/manageEvent.js +258 -258
  23. package/dist/tools/calendar/moveEvent.js +66 -66
  24. package/dist/tools/docs/addTab.js +10 -12
  25. package/dist/tools/docs/appendToGoogleDoc.js +9 -4
  26. package/dist/tools/docs/deleteRange.js +9 -4
  27. package/dist/tools/docs/findAndReplace.js +9 -4
  28. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
  29. package/dist/tools/docs/formatting/getFormatting.js +171 -171
  30. package/dist/tools/docs/insertImage.js +15 -2
  31. package/dist/tools/docs/insertPageBreak.js +4 -1
  32. package/dist/tools/docs/insertTable.js +4 -1
  33. package/dist/tools/docs/insertTableWithData.js +4 -1
  34. package/dist/tools/docs/modifyText.js +14 -4
  35. package/dist/tools/docs/modifyText.test.js +84 -84
  36. package/dist/tools/docs/readGoogleDoc.js +71 -11
  37. package/dist/tools/docs/renameTab.js +12 -14
  38. package/dist/tools/drafts.js +165 -165
  39. package/dist/tools/drive/createDocument.js +13 -1
  40. package/dist/tools/drive/downloadFile.js +268 -268
  41. package/dist/tools/drive/getFileInfo.js +48 -48
  42. package/dist/tools/drive/getFilePath.js +50 -50
  43. package/dist/tools/drive/listDriveFiles.js +112 -112
  44. package/dist/tools/drive/listSharedDrives.js +52 -52
  45. package/dist/tools/drive/listSharedWithMe.js +97 -97
  46. package/dist/tools/drive/uploadFile.js +111 -111
  47. package/dist/tools/extras/index.js +9 -9
  48. package/dist/tools/extras/readDriveFile.js +212 -206
  49. package/dist/tools/extras/readFile.js +84 -84
  50. package/dist/tools/extras/searchFileContents.js +81 -81
  51. package/dist/tools/forms/batchUpdateForm.js +81 -81
  52. package/dist/tools/forms/createForm.js +54 -54
  53. package/dist/tools/forms/getForm.js +118 -118
  54. package/dist/tools/forms/getFormResponse.js +45 -45
  55. package/dist/tools/forms/index.js +15 -15
  56. package/dist/tools/forms/listFormResponses.js +56 -56
  57. package/dist/tools/forms/setPublishSettings.js +59 -59
  58. package/dist/tools/gmail/drafts.js +165 -165
  59. package/dist/tools/gmail/labels.js +54 -83
  60. package/dist/tools/gmail/messages.js +444 -441
  61. package/dist/tools/gmail/settings.js +358 -528
  62. package/dist/tools/gmail/threads.js +286 -164
  63. package/dist/tools/index.js +511 -473
  64. package/dist/tools/labels.js +103 -103
  65. package/dist/tools/legacyAliases.js +426 -0
  66. package/dist/tools/maps/directions.js +72 -0
  67. package/dist/tools/maps/geocode.js +16 -0
  68. package/dist/tools/maps/index.js +15 -0
  69. package/dist/tools/maps/mapsClient.js +83 -0
  70. package/dist/tools/maps/placeDetails.js +16 -0
  71. package/dist/tools/maps/reverseGeocode.js +19 -0
  72. package/dist/tools/maps/searchNearby.js +54 -0
  73. package/dist/tools/maps/searchPlaces.js +24 -0
  74. package/dist/tools/messages.js +448 -448
  75. package/dist/tools/settings.js +528 -528
  76. package/dist/tools/slides/createPresentation.js +104 -104
  77. package/dist/tools/slides/createShape.js +92 -92
  78. package/dist/tools/slides/createTextBox.js +82 -82
  79. package/dist/tools/slides/deleteSlide.js +30 -30
  80. package/dist/tools/slides/duplicateSlide.js +37 -37
  81. package/dist/tools/slides/exportThumbnail.js +42 -42
  82. package/dist/tools/slides/formatParagraph.js +72 -72
  83. package/dist/tools/slides/formatText.js +84 -84
  84. package/dist/tools/slides/getPresentation.js +87 -87
  85. package/dist/tools/slides/index.js +33 -33
  86. package/dist/tools/slides/reorderSlides.js +41 -41
  87. package/dist/tools/slides/replaceAllText.js +46 -46
  88. package/dist/tools/slides/setBackground.js +58 -58
  89. package/dist/tools/slides/speakerNotes.js +102 -102
  90. package/dist/tools/slides/styleShape.js +111 -111
  91. package/dist/tools/slides/updatePresentation.js +128 -128
  92. package/dist/tools/threads.js +145 -145
  93. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +31 -7
  94. package/dist/tools/utils/replaceDocumentWithMarkdown.js +72 -9
  95. package/dist/updateCheck.js +202 -0
  96. package/dist/workspace.js +122 -0
  97. package/package.json +82 -80
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { getLastReadRevisionId, trackMutation } from '../../readTracker.js';
6
7
  export function register(server) {
7
8
  server.addTool({
8
9
  name: 'addTab',
@@ -52,19 +53,16 @@ export function register(server) {
52
53
  tabProperties.index = args.index;
53
54
  if (args.iconEmoji !== undefined)
54
55
  tabProperties.iconEmoji = args.iconEmoji;
55
- const response = await docs.documents.batchUpdate({
56
- documentId: args.documentId,
57
- requestBody: {
58
- requests: [
59
- {
60
- addDocumentTab: {
61
- tabProperties,
62
- },
63
- },
64
- ],
56
+ const revisionId = getLastReadRevisionId(args.documentId);
57
+ const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
58
+ {
59
+ addDocumentTab: {
60
+ tabProperties,
61
+ },
65
62
  },
66
- });
67
- const newTabProps = response.data.replies?.[0]?.addDocumentTab?.tabProperties;
63
+ ], revisionId ? { requiredRevisionId: revisionId } : undefined);
64
+ trackMutation(args.documentId, response?.writeControl?.requiredRevisionId);
65
+ const newTabProps = response.replies?.[0]?.addDocumentTab?.tabProperties;
68
66
  if (newTabProps) {
69
67
  return JSON.stringify({
70
68
  url: `https://docs.google.com/document/d/${args.documentId}/edit`,
@@ -5,7 +5,7 @@ import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, NotImplementedError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
7
  import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
8
- import { guardMutation, trackMutation } from '../../readTracker.js';
8
+ import { guardMutation, getLastReadRevisionId, trackMutation } from '../../readTracker.js';
9
9
  export function register(server) {
10
10
  server.addTool({
11
11
  name: 'appendText',
@@ -29,7 +29,11 @@ export function register(server) {
29
29
  await guardMutation(args.documentId, {
30
30
  contentFetcher: async () => {
31
31
  const current = await docs.documents.get({ documentId: args.documentId });
32
- return docsJsonToMarkdown(current.data);
32
+ // Return the revision this content came from alongside the
33
+ // content itself so guardMutation can refresh both together
34
+ // instead of leaving revisionId stale after a diff (see
35
+ // readTracker.js guardMutation for why that matters).
36
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
33
37
  },
34
38
  });
35
39
  // Resolve text content from filePath or inline parameter
@@ -88,8 +92,9 @@ export function register(server) {
88
92
  const request = {
89
93
  insertText: { location, text: textToInsert },
90
94
  };
91
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
92
- trackMutation(args.documentId);
95
+ const revisionId = getLastReadRevisionId(args.documentId);
96
+ const writeResponse = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request], revisionId ? { requiredRevisionId: revisionId } : undefined);
97
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
93
98
  log.info(`Successfully appended to doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
94
99
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
95
100
  return `${docUrl}\nSuccessfully appended text to ${args.tabId ? `tab ${args.tabId} in ` : ''}document ${args.documentId}.`;
@@ -4,7 +4,7 @@ import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
6
  import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
7
- import { guardMutation, trackMutation } from '../../readTracker.js';
7
+ import { guardMutation, getLastReadRevisionId, trackMutation } from '../../readTracker.js';
8
8
  export function register(server) {
9
9
  server.addTool({
10
10
  name: 'deleteRange',
@@ -33,7 +33,11 @@ export function register(server) {
33
33
  await guardMutation(args.documentId, {
34
34
  contentFetcher: async () => {
35
35
  const current = await docs.documents.get({ documentId: args.documentId });
36
- return docsJsonToMarkdown(current.data);
36
+ // Return the revision this content came from alongside the
37
+ // content itself so guardMutation can refresh both together
38
+ // instead of leaving revisionId stale after a diff (see
39
+ // readTracker.js guardMutation for why that matters).
40
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
37
41
  },
38
42
  });
39
43
  log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
@@ -66,8 +70,9 @@ export function register(server) {
66
70
  const request = {
67
71
  deleteContentRange: { range },
68
72
  };
69
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
70
- trackMutation(args.documentId);
73
+ const revisionId = getLastReadRevisionId(args.documentId);
74
+ const writeResponse = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request], revisionId ? { requiredRevisionId: revisionId } : undefined);
75
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
71
76
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
72
77
  return `${docUrl}\nSuccessfully deleted content in range ${args.startIndex}-${args.endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
73
78
  }
@@ -4,7 +4,7 @@ import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
6
  import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
7
- import { guardMutation, trackMutation } from '../../readTracker.js';
7
+ import { guardMutation, getLastReadRevisionId, trackMutation } from '../../readTracker.js';
8
8
  const FindAndReplaceParameters = DocumentIdParameter.extend({
9
9
  findText: z.string().min(1).describe('The text to search for in the document.'),
10
10
  replaceText: z
@@ -30,7 +30,11 @@ export function register(server) {
30
30
  await guardMutation(args.documentId, {
31
31
  contentFetcher: async () => {
32
32
  const current = await docs.documents.get({ documentId: args.documentId });
33
- return docsJsonToMarkdown(current.data);
33
+ // Return the revision this content came from alongside the
34
+ // content itself so guardMutation can refresh both together
35
+ // instead of leaving revisionId stale after a diff (see
36
+ // readTracker.js guardMutation for why that matters).
37
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
34
38
  },
35
39
  });
36
40
  log.info(`findAndReplace in doc ${args.documentId}: "${args.findText}" → "${args.replaceText}"` +
@@ -47,8 +51,9 @@ export function register(server) {
47
51
  ...(args.tabId && { tabsCriteria: { tabIds: [args.tabId] } }),
48
52
  },
49
53
  };
50
- const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
51
- trackMutation(args.documentId);
54
+ const revisionId = getLastReadRevisionId(args.documentId);
55
+ const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request], revisionId ? { requiredRevisionId: revisionId } : undefined);
56
+ trackMutation(args.documentId, response?.writeControl?.requiredRevisionId);
52
57
  const changed = response.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
53
58
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
54
59
  return `${docUrl}\nReplaced ${changed} occurrence(s) of "${args.findText}" with "${args.replaceText}".`;
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { getDocsClient } from '../../../clients.js';
3
3
  import { ApplyParagraphStyleToolParameters, NotImplementedError, } from '../../../types.js';
4
4
  import * as GDocsHelpers from '../../../googleDocsApiHelpers.js';
5
+ import { getLastReadRevisionId, trackMutation } from '../../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'applyParagraphStyle',
@@ -64,7 +65,9 @@ export function register(server) {
64
65
  return 'No valid paragraph styling options were provided.';
65
66
  }
66
67
  log.info(`Applying styles: ${requestInfo.fields.join(', ')}`);
67
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
68
+ const revisionId = getLastReadRevisionId(args.documentId);
69
+ const writeResponse = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request], revisionId ? { requiredRevisionId: revisionId } : undefined);
70
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
68
71
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
69
72
  return `${docUrl}\nSuccessfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
70
73
  }
@@ -1,171 +1,171 @@
1
- import { UserError } from 'fastmcp';
2
- import { z } from 'zod';
3
- import { getDocsClient } from '../../../clients.js';
4
- import { DocumentIdParameter, TextFindParameter } from '../../../types.js';
5
- import * as GDocsHelpers from '../../../googleDocsApiHelpers.js';
6
-
7
- const RangeTarget = z.object({
8
- startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
9
- endIndex: z.number().int().min(1).describe('End of range (exclusive).'),
10
- }).refine((d) => d.endIndex > d.startIndex, {
11
- message: 'endIndex must be greater than startIndex',
12
- path: ['endIndex'],
13
- });
14
-
15
- const GetFormattingParameters = DocumentIdParameter.extend({
16
- target: z
17
- .union([RangeTarget, TextFindParameter])
18
- .describe('Target by range indices or text search.'),
19
- tabId: z
20
- .string()
21
- .optional()
22
- .describe('The ID of the specific tab to read from. If not specified, reads from the first tab.'),
23
- });
24
-
25
- /**
26
- * Extracts text style and paragraph style info from document elements
27
- * that overlap the given range.
28
- */
29
- function extractFormattingFromContent(bodyContent, startIndex, endIndex) {
30
- const textStyles = [];
31
- const paragraphStyles = [];
32
-
33
- for (const element of bodyContent) {
34
- if (!element.paragraph) continue;
35
-
36
- const para = element.paragraph;
37
- // Check if this paragraph overlaps our range
38
- const paraElements = para.elements || [];
39
- let paragraphOverlaps = false;
40
-
41
- for (const pe of paraElements) {
42
- const elStart = pe.startIndex ?? 0;
43
- const elEnd = pe.endIndex ?? 0;
44
-
45
- // Check overlap with our target range
46
- if (elEnd > startIndex && elStart < endIndex) {
47
- paragraphOverlaps = true;
48
- if (pe.textRun) {
49
- const text = pe.textRun.content || '';
50
- const style = pe.textRun.textStyle || {};
51
- // Only include non-empty style properties
52
- const cleanStyle = {};
53
- if (style.bold) cleanStyle.bold = true;
54
- if (style.italic) cleanStyle.italic = true;
55
- if (style.underline) cleanStyle.underline = true;
56
- if (style.strikethrough) cleanStyle.strikethrough = true;
57
- if (style.fontSize) cleanStyle.fontSize = style.fontSize;
58
- if (style.foregroundColor?.color?.rgbColor) cleanStyle.foregroundColor = style.foregroundColor.color.rgbColor;
59
- if (style.backgroundColor?.color?.rgbColor) cleanStyle.backgroundColor = style.backgroundColor.color.rgbColor;
60
- if (style.weightedFontFamily) cleanStyle.fontFamily = style.weightedFontFamily.fontFamily;
61
- if (style.link) cleanStyle.link = style.link;
62
- if (style.baselineOffset && style.baselineOffset !== 'BASELINE_OFFSET_UNSPECIFIED') cleanStyle.baselineOffset = style.baselineOffset;
63
-
64
- textStyles.push({
65
- startIndex: elStart,
66
- endIndex: elEnd,
67
- text: text.replace(/\n$/, ''),
68
- style: cleanStyle,
69
- });
70
- }
71
- }
72
- }
73
-
74
- if (paragraphOverlaps && para.paragraphStyle) {
75
- const ps = para.paragraphStyle;
76
- const cleanStyle = {};
77
- if (ps.namedStyleType && ps.namedStyleType !== 'NORMAL_TEXT') cleanStyle.namedStyleType = ps.namedStyleType;
78
- if (ps.alignment && ps.alignment !== 'START') cleanStyle.alignment = ps.alignment;
79
- if (ps.indentStart?.magnitude) cleanStyle.indentStart = ps.indentStart.magnitude;
80
- if (ps.indentEnd?.magnitude) cleanStyle.indentEnd = ps.indentEnd.magnitude;
81
- if (ps.spaceAbove?.magnitude) cleanStyle.spaceAbove = ps.spaceAbove.magnitude;
82
- if (ps.spaceBelow?.magnitude) cleanStyle.spaceBelow = ps.spaceBelow.magnitude;
83
- if (ps.keepWithNext) cleanStyle.keepWithNext = true;
84
- if (ps.lineSpacing) cleanStyle.lineSpacing = ps.lineSpacing;
85
- if (ps.direction && ps.direction !== 'LEFT_TO_RIGHT') cleanStyle.direction = ps.direction;
86
-
87
- // Always include namedStyleType even if NORMAL_TEXT for clarity
88
- if (!cleanStyle.namedStyleType) cleanStyle.namedStyleType = 'NORMAL_TEXT';
89
-
90
- paragraphStyles.push({
91
- paragraphStartIndex: element.startIndex,
92
- paragraphEndIndex: element.endIndex,
93
- style: cleanStyle,
94
- });
95
- }
96
- }
97
-
98
- return { textStyles, paragraphStyles };
99
- }
100
-
101
- export function register(server) {
102
- server.addTool({
103
- name: 'getFormatting',
104
- description: 'Returns the text styling (bold, italic, font, colors, etc.) and paragraph styling ' +
105
- '(alignment, headings, spacing, indentation) for a specific range or found text in a Google Doc. ' +
106
- 'Returns per-run text styles and per-paragraph styles, so mixed formatting (e.g. bold + italic spans) is fully represented.',
107
- parameters: GetFormattingParameters,
108
- execute: async (args, { log }) => {
109
- const docs = await getDocsClient();
110
- log.info(`getFormatting on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
111
- `${args.tabId ? `, tab=${args.tabId}` : ''}`);
112
- try {
113
- // Resolve target to numeric indices
114
- let startIndex;
115
- let endIndex;
116
-
117
- if ('textToFind' in args.target) {
118
- const range = await GDocsHelpers.findTextRange(
119
- docs, args.documentId, args.target.textToFind,
120
- args.target.matchInstance, args.tabId
121
- );
122
- if (!range) {
123
- throw new UserError(
124
- `Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`
125
- );
126
- }
127
- startIndex = range.startIndex;
128
- endIndex = range.endIndex;
129
- } else {
130
- startIndex = args.target.startIndex;
131
- endIndex = args.target.endIndex;
132
- }
133
-
134
- // Fetch document with full content
135
- const needsTabsContent = !!args.tabId;
136
- const res = await docs.documents.get({
137
- documentId: args.documentId,
138
- ...(needsTabsContent && { includeTabsContent: true }),
139
- fields: needsTabsContent ? '*' : 'body(content)',
140
- });
141
-
142
- let bodyContent;
143
- if (args.tabId) {
144
- const targetTab = GDocsHelpers.findTabById(res.data, args.tabId);
145
- if (!targetTab) {
146
- throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
147
- }
148
- bodyContent = targetTab.documentTab?.body?.content;
149
- } else {
150
- bodyContent = res.data.body?.content;
151
- }
152
-
153
- if (!bodyContent) {
154
- throw new UserError('Document has no content.');
155
- }
156
-
157
- const result = extractFormattingFromContent(bodyContent, startIndex, endIndex);
158
-
159
- return JSON.stringify({
160
- range: { startIndex, endIndex },
161
- textStyles: result.textStyles,
162
- paragraphStyles: result.paragraphStyles,
163
- }, null, 2);
164
- } catch (error) {
165
- log.error(`Error in getFormatting for doc ${args.documentId}: ${error.message || error}`);
166
- if (error instanceof UserError) throw error;
167
- throw new UserError(`Failed to get formatting: ${error.message || 'Unknown error'}`);
168
- }
169
- },
170
- });
171
- }
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDocsClient } from '../../../clients.js';
4
+ import { DocumentIdParameter, TextFindParameter } from '../../../types.js';
5
+ import * as GDocsHelpers from '../../../googleDocsApiHelpers.js';
6
+
7
+ const RangeTarget = z.object({
8
+ startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
9
+ endIndex: z.number().int().min(1).describe('End of range (exclusive).'),
10
+ }).refine((d) => d.endIndex > d.startIndex, {
11
+ message: 'endIndex must be greater than startIndex',
12
+ path: ['endIndex'],
13
+ });
14
+
15
+ const GetFormattingParameters = DocumentIdParameter.extend({
16
+ target: z
17
+ .union([RangeTarget, TextFindParameter])
18
+ .describe('Target by range indices or text search.'),
19
+ tabId: z
20
+ .string()
21
+ .optional()
22
+ .describe('The ID of the specific tab to read from. If not specified, reads from the first tab.'),
23
+ });
24
+
25
+ /**
26
+ * Extracts text style and paragraph style info from document elements
27
+ * that overlap the given range.
28
+ */
29
+ function extractFormattingFromContent(bodyContent, startIndex, endIndex) {
30
+ const textStyles = [];
31
+ const paragraphStyles = [];
32
+
33
+ for (const element of bodyContent) {
34
+ if (!element.paragraph) continue;
35
+
36
+ const para = element.paragraph;
37
+ // Check if this paragraph overlaps our range
38
+ const paraElements = para.elements || [];
39
+ let paragraphOverlaps = false;
40
+
41
+ for (const pe of paraElements) {
42
+ const elStart = pe.startIndex ?? 0;
43
+ const elEnd = pe.endIndex ?? 0;
44
+
45
+ // Check overlap with our target range
46
+ if (elEnd > startIndex && elStart < endIndex) {
47
+ paragraphOverlaps = true;
48
+ if (pe.textRun) {
49
+ const text = pe.textRun.content || '';
50
+ const style = pe.textRun.textStyle || {};
51
+ // Only include non-empty style properties
52
+ const cleanStyle = {};
53
+ if (style.bold) cleanStyle.bold = true;
54
+ if (style.italic) cleanStyle.italic = true;
55
+ if (style.underline) cleanStyle.underline = true;
56
+ if (style.strikethrough) cleanStyle.strikethrough = true;
57
+ if (style.fontSize) cleanStyle.fontSize = style.fontSize;
58
+ if (style.foregroundColor?.color?.rgbColor) cleanStyle.foregroundColor = style.foregroundColor.color.rgbColor;
59
+ if (style.backgroundColor?.color?.rgbColor) cleanStyle.backgroundColor = style.backgroundColor.color.rgbColor;
60
+ if (style.weightedFontFamily) cleanStyle.fontFamily = style.weightedFontFamily.fontFamily;
61
+ if (style.link) cleanStyle.link = style.link;
62
+ if (style.baselineOffset && style.baselineOffset !== 'BASELINE_OFFSET_UNSPECIFIED') cleanStyle.baselineOffset = style.baselineOffset;
63
+
64
+ textStyles.push({
65
+ startIndex: elStart,
66
+ endIndex: elEnd,
67
+ text: text.replace(/\n$/, ''),
68
+ style: cleanStyle,
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ if (paragraphOverlaps && para.paragraphStyle) {
75
+ const ps = para.paragraphStyle;
76
+ const cleanStyle = {};
77
+ if (ps.namedStyleType && ps.namedStyleType !== 'NORMAL_TEXT') cleanStyle.namedStyleType = ps.namedStyleType;
78
+ if (ps.alignment && ps.alignment !== 'START') cleanStyle.alignment = ps.alignment;
79
+ if (ps.indentStart?.magnitude) cleanStyle.indentStart = ps.indentStart.magnitude;
80
+ if (ps.indentEnd?.magnitude) cleanStyle.indentEnd = ps.indentEnd.magnitude;
81
+ if (ps.spaceAbove?.magnitude) cleanStyle.spaceAbove = ps.spaceAbove.magnitude;
82
+ if (ps.spaceBelow?.magnitude) cleanStyle.spaceBelow = ps.spaceBelow.magnitude;
83
+ if (ps.keepWithNext) cleanStyle.keepWithNext = true;
84
+ if (ps.lineSpacing) cleanStyle.lineSpacing = ps.lineSpacing;
85
+ if (ps.direction && ps.direction !== 'LEFT_TO_RIGHT') cleanStyle.direction = ps.direction;
86
+
87
+ // Always include namedStyleType even if NORMAL_TEXT for clarity
88
+ if (!cleanStyle.namedStyleType) cleanStyle.namedStyleType = 'NORMAL_TEXT';
89
+
90
+ paragraphStyles.push({
91
+ paragraphStartIndex: element.startIndex,
92
+ paragraphEndIndex: element.endIndex,
93
+ style: cleanStyle,
94
+ });
95
+ }
96
+ }
97
+
98
+ return { textStyles, paragraphStyles };
99
+ }
100
+
101
+ export function register(server) {
102
+ server.addTool({
103
+ name: 'getFormatting',
104
+ description: 'Returns the text styling (bold, italic, font, colors, etc.) and paragraph styling ' +
105
+ '(alignment, headings, spacing, indentation) for a specific range or found text in a Google Doc. ' +
106
+ 'Returns per-run text styles and per-paragraph styles, so mixed formatting (e.g. bold + italic spans) is fully represented.',
107
+ parameters: GetFormattingParameters,
108
+ execute: async (args, { log }) => {
109
+ const docs = await getDocsClient();
110
+ log.info(`getFormatting on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
111
+ `${args.tabId ? `, tab=${args.tabId}` : ''}`);
112
+ try {
113
+ // Resolve target to numeric indices
114
+ let startIndex;
115
+ let endIndex;
116
+
117
+ if ('textToFind' in args.target) {
118
+ const range = await GDocsHelpers.findTextRange(
119
+ docs, args.documentId, args.target.textToFind,
120
+ args.target.matchInstance, args.tabId
121
+ );
122
+ if (!range) {
123
+ throw new UserError(
124
+ `Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`
125
+ );
126
+ }
127
+ startIndex = range.startIndex;
128
+ endIndex = range.endIndex;
129
+ } else {
130
+ startIndex = args.target.startIndex;
131
+ endIndex = args.target.endIndex;
132
+ }
133
+
134
+ // Fetch document with full content
135
+ const needsTabsContent = !!args.tabId;
136
+ const res = await docs.documents.get({
137
+ documentId: args.documentId,
138
+ ...(needsTabsContent && { includeTabsContent: true }),
139
+ fields: needsTabsContent ? '*' : 'body(content)',
140
+ });
141
+
142
+ let bodyContent;
143
+ if (args.tabId) {
144
+ const targetTab = GDocsHelpers.findTabById(res.data, args.tabId);
145
+ if (!targetTab) {
146
+ throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
147
+ }
148
+ bodyContent = targetTab.documentTab?.body?.content;
149
+ } else {
150
+ bodyContent = res.data.body?.content;
151
+ }
152
+
153
+ if (!bodyContent) {
154
+ throw new UserError('Document has no content.');
155
+ }
156
+
157
+ const result = extractFormattingFromContent(bodyContent, startIndex, endIndex);
158
+
159
+ return JSON.stringify({
160
+ range: { startIndex, endIndex },
161
+ textStyles: result.textStyles,
162
+ paragraphStyles: result.paragraphStyles,
163
+ }, null, 2);
164
+ } catch (error) {
165
+ log.error(`Error in getFormatting for doc ${args.documentId}: ${error.message || error}`);
166
+ if (error instanceof UserError) throw error;
167
+ throw new UserError(`Failed to get formatting: ${error.message || 'Unknown error'}`);
168
+ }
169
+ },
170
+ });
171
+ }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getDocsClient, getDriveClient, getScriptClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { getLastReadRevisionId, requireRereadBeforeMutation, trackMutation } from '../../readTracker.js';
6
7
  export function register(server) {
7
8
  server.addTool({
8
9
  name: 'insertImage',
@@ -75,7 +76,17 @@ export function register(server) {
75
76
  const driveFileId = await GDocsHelpers.uploadImageToDrive(drive, args.localImagePath, parentFolderId, true // skipPublicSharing
76
77
  );
77
78
  log.info(`[AppsScript] Inserting image via marker at index ${args.index} (fileId: ${driveFileId})`);
78
- await GDocsHelpers.insertImageViaAppsScript(docs, scriptClient, appsScriptDeploymentId, args.documentId, driveFileId, args.index, args.tabId);
79
+ const appsScriptRevisionId = getLastReadRevisionId(args.documentId);
80
+ await GDocsHelpers.insertImageViaAppsScript(docs, scriptClient, appsScriptDeploymentId, args.documentId, driveFileId, args.index, args.tabId, appsScriptRevisionId ? { requiredRevisionId: appsScriptRevisionId } : undefined);
81
+ // The Apps Script call that replaces the marker with the image mutates
82
+ // the document outside our batchUpdate visibility, so we have no way to
83
+ // learn the true post-write revision here. trackMutation would clear the
84
+ // revision and the modifiedTime, which leaves the next write with nothing
85
+ // to check against and sends it out unguarded on a pre-image baseline.
86
+ // Require a fresh read instead.
87
+ log.warn(`Document ${args.documentId} must be read again before the next write ` +
88
+ '(the Apps Script mutates the doc outside batchUpdate visibility, so no true post-write revision is available).');
89
+ requireRereadBeforeMutation(args.documentId, 'an Apps Script inserted an image and its resulting revision is not visible to us.');
79
90
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
80
91
  return `${docUrl}\nSuccessfully inserted local image at index ${args.index} via Apps Script${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
81
92
  }
@@ -105,7 +116,9 @@ export function register(server) {
105
116
  resolvedUrl = args.imageUrl;
106
117
  log.info(`Inserting image from URL ${resolvedUrl} at index ${args.index} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
107
118
  }
108
- await GDocsHelpers.insertInlineImage(docs, args.documentId, resolvedUrl, args.index, args.width, args.height, args.tabId);
119
+ const revisionId = getLastReadRevisionId(args.documentId);
120
+ const writeResponse = await GDocsHelpers.insertInlineImage(docs, args.documentId, resolvedUrl, args.index, args.width, args.height, args.tabId, revisionId ? { requiredRevisionId: revisionId } : undefined);
121
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
109
122
  let sizeInfo = '';
110
123
  if (args.width && args.height) {
111
124
  sizeInfo = ` with size ${args.width}x${args.height}pt`;
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { getLastReadRevisionId, trackMutation } from '../../readTracker.js';
6
7
  export function register(server) {
7
8
  server.addTool({
8
9
  name: 'insertPageBreak',
@@ -44,7 +45,9 @@ export function register(server) {
44
45
  const request = {
45
46
  insertPageBreak: { location },
46
47
  };
47
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
48
+ const revisionId = getLastReadRevisionId(args.documentId);
49
+ const writeResponse = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request], revisionId ? { requiredRevisionId: revisionId } : undefined);
50
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
48
51
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
49
52
  return `${docUrl}\nSuccessfully inserted page break at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
50
53
  }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { getLastReadRevisionId, trackMutation } from '../../readTracker.js';
6
7
  export function register(server) {
7
8
  server.addTool({
8
9
  name: 'insertTable',
@@ -39,7 +40,9 @@ export function register(server) {
39
40
  throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`);
40
41
  }
41
42
  }
42
- await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index, args.tabId);
43
+ const revisionId = getLastReadRevisionId(args.documentId);
44
+ const writeResponse = await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index, args.tabId, revisionId ? { requiredRevisionId: revisionId } : undefined);
45
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
43
46
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
44
47
  return `${docUrl}\nSuccessfully inserted a ${args.rows}x${args.columns} table at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
45
48
  }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { getLastReadRevisionId, trackMutation } from '../../readTracker.js';
6
7
  // --- Table Index Math ---
7
8
  // Google Docs API table index layout for an R×C table inserted at T:
8
9
  // cellContentIndex(T, r, c, C) = T + 4 + r * (1 + 2*C) + 2*c
@@ -118,7 +119,9 @@ export function register(server) {
118
119
  }
119
120
  }
120
121
  const requests = buildInsertTableWithDataRequests(args.data, args.index, args.hasHeaderRow ?? false, args.tabId);
121
- const metadata = await GDocsHelpers.executeBatchUpdateWithSplitting(docs, args.documentId, requests, log);
122
+ const revisionId = getLastReadRevisionId(args.documentId);
123
+ const metadata = await GDocsHelpers.executeBatchUpdateWithSplitting(docs, args.documentId, requests, log, revisionId ? { requiredRevisionId: revisionId } : undefined);
124
+ trackMutation(args.documentId, metadata?.finalWriteControl?.requiredRevisionId);
122
125
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
123
126
  return (`${docUrl}\nSuccessfully inserted a ${numRows}x${numCols} table with data at index ${args.index}` +
124
127
  `${args.tabId ? ` in tab ${args.tabId}` : ''}. ` +