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
@@ -4,7 +4,7 @@ import { getDocsClient } from '../../clients.js';
4
4
  import { DocumentIdParameter, TextFindParameter, TextStyleParameters, ParagraphStyleParameters } 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 RangeTarget = z
9
9
  .object({
10
10
  startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
@@ -110,7 +110,11 @@ export function register(server) {
110
110
  await guardMutation(args.documentId, {
111
111
  contentFetcher: async () => {
112
112
  const current = await docs.documents.get({ documentId: args.documentId });
113
- return docsJsonToMarkdown(current.data);
113
+ // Return the revision this content came from alongside the
114
+ // content itself so guardMutation can refresh both together
115
+ // instead of leaving revisionId stale after a diff (see
116
+ // readTracker.js guardMutation for why that matters).
117
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
114
118
  },
115
119
  });
116
120
  log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
@@ -173,8 +177,14 @@ export function register(server) {
173
177
  if (requests.length === 0) {
174
178
  return 'No operations to perform.';
175
179
  }
176
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests);
177
- trackMutation(args.documentId);
180
+ const revisionId = getLastReadRevisionId(args.documentId);
181
+ const writeResponse = await GDocsHelpers.executeBatchUpdate(
182
+ docs,
183
+ args.documentId,
184
+ requests,
185
+ revisionId ? { requiredRevisionId: revisionId } : undefined
186
+ );
187
+ trackMutation(args.documentId, writeResponse?.writeControl?.requiredRevisionId);
178
188
  // Build descriptive result
179
189
  const actions = [];
180
190
  if (endIndex !== undefined && normalizedText === '')
@@ -2,91 +2,91 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { buildModifyTextRequests } from './modifyText.js';
4
4
  import { stripMarkdownListMarkersForSearch } from '../../googleDocsApiHelpers.js';
5
-
6
- describe('buildModifyTextRequests', () => {
7
- it('generates deleteContentRange + insertText for a replacement', () => {
8
- const reqs = buildModifyTextRequests({
9
- startIndex: 5,
10
- endIndex: 10,
11
- text: 'hello',
12
- });
13
- assert.equal(reqs.length, 2);
14
- assert.deepEqual(reqs[0], {
15
- deleteContentRange: { range: { startIndex: 5, endIndex: 10 } },
16
- });
17
- assert.deepEqual(reqs[1], {
18
- insertText: { location: { index: 5 }, text: 'hello' },
19
- });
20
- });
21
-
22
- it('generates only insertText for an insertion (no endIndex)', () => {
23
- const reqs = buildModifyTextRequests({
24
- startIndex: 3,
25
- endIndex: undefined,
26
- text: 'hi',
27
- });
28
- assert.equal(reqs.length, 1);
29
- assert.deepEqual(reqs[0], {
30
- insertText: { location: { index: 3 }, text: 'hi' },
31
- });
32
- });
33
-
34
- it('returns empty array when no text/style/paragraphStyle given', () => {
35
- const reqs = buildModifyTextRequests({
36
- startIndex: 1,
37
- endIndex: 5,
38
- });
39
- assert.equal(reqs.length, 0);
40
- });
41
-
42
- it('includes tabId in range and location when provided', () => {
43
- const reqs = buildModifyTextRequests({
44
- startIndex: 1,
45
- endIndex: 4,
46
- text: 'x',
47
- tabId: 'tab1',
48
- });
49
- assert.deepEqual(reqs[0].deleteContentRange.range.tabId, 'tab1');
50
- assert.deepEqual(reqs[1].insertText.location.tabId, 'tab1');
51
- });
52
- });
53
-
5
+
6
+ describe('buildModifyTextRequests', () => {
7
+ it('generates deleteContentRange + insertText for a replacement', () => {
8
+ const reqs = buildModifyTextRequests({
9
+ startIndex: 5,
10
+ endIndex: 10,
11
+ text: 'hello',
12
+ });
13
+ assert.equal(reqs.length, 2);
14
+ assert.deepEqual(reqs[0], {
15
+ deleteContentRange: { range: { startIndex: 5, endIndex: 10 } },
16
+ });
17
+ assert.deepEqual(reqs[1], {
18
+ insertText: { location: { index: 5 }, text: 'hello' },
19
+ });
20
+ });
21
+
22
+ it('generates only insertText for an insertion (no endIndex)', () => {
23
+ const reqs = buildModifyTextRequests({
24
+ startIndex: 3,
25
+ endIndex: undefined,
26
+ text: 'hi',
27
+ });
28
+ assert.equal(reqs.length, 1);
29
+ assert.deepEqual(reqs[0], {
30
+ insertText: { location: { index: 3 }, text: 'hi' },
31
+ });
32
+ });
33
+
34
+ it('returns empty array when no text/style/paragraphStyle given', () => {
35
+ const reqs = buildModifyTextRequests({
36
+ startIndex: 1,
37
+ endIndex: 5,
38
+ });
39
+ assert.equal(reqs.length, 0);
40
+ });
41
+
42
+ it('includes tabId in range and location when provided', () => {
43
+ const reqs = buildModifyTextRequests({
44
+ startIndex: 1,
45
+ endIndex: 4,
46
+ text: 'x',
47
+ tabId: 'tab1',
48
+ });
49
+ assert.deepEqual(reqs[0].deleteContentRange.range.tabId, 'tab1');
50
+ assert.deepEqual(reqs[1].insertText.location.tabId, 'tab1');
51
+ });
52
+ });
53
+
54
54
  describe('escape sequence normalization (issue #9)', () => {
55
- // Simulate the normalization that happens in the execute handler
56
- function normalize(text) {
57
- return text?.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
58
- }
59
-
60
- it('converts literal \\n to real newline', () => {
61
- const input = 'line one\\nline two\\nline three';
62
- const result = normalize(input);
63
- assert.equal(result, 'line one\nline two\nline three');
64
- });
65
-
66
- it('converts literal \\t to real tab', () => {
67
- const input = 'col1\\tcol2\\tcol3';
68
- const result = normalize(input);
69
- assert.equal(result, 'col1\tcol2\tcol3');
70
- });
71
-
72
- it('handles mixed \\n and \\t', () => {
73
- const input = 'row1\\tcol2\\nrow2\\tcol2';
74
- const result = normalize(input);
75
- assert.equal(result, 'row1\tcol2\nrow2\tcol2');
76
- });
77
-
78
- it('leaves text without escape sequences unchanged', () => {
79
- const input = 'no escapes here';
80
- assert.equal(normalize(input), 'no escapes here');
81
- });
82
-
83
- it('preserves real newlines that are already in the string', () => {
84
- const input = 'already\nreal';
85
- assert.equal(normalize(input), 'already\nreal');
86
- });
87
-
88
- it('returns undefined for undefined input', () => {
89
- assert.equal(normalize(undefined), undefined);
55
+ // Simulate the normalization that happens in the execute handler
56
+ function normalize(text) {
57
+ return text?.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
58
+ }
59
+
60
+ it('converts literal \\n to real newline', () => {
61
+ const input = 'line one\\nline two\\nline three';
62
+ const result = normalize(input);
63
+ assert.equal(result, 'line one\nline two\nline three');
64
+ });
65
+
66
+ it('converts literal \\t to real tab', () => {
67
+ const input = 'col1\\tcol2\\tcol3';
68
+ const result = normalize(input);
69
+ assert.equal(result, 'col1\tcol2\tcol3');
70
+ });
71
+
72
+ it('handles mixed \\n and \\t', () => {
73
+ const input = 'row1\\tcol2\\nrow2\\tcol2';
74
+ const result = normalize(input);
75
+ assert.equal(result, 'row1\tcol2\nrow2\tcol2');
76
+ });
77
+
78
+ it('leaves text without escape sequences unchanged', () => {
79
+ const input = 'no escapes here';
80
+ assert.equal(normalize(input), 'no escapes here');
81
+ });
82
+
83
+ it('preserves real newlines that are already in the string', () => {
84
+ const input = 'already\nreal';
85
+ assert.equal(normalize(input), 'already\nreal');
86
+ });
87
+
88
+ it('returns undefined for undefined input', () => {
89
+ assert.equal(normalize(undefined), undefined);
90
90
  });
91
91
  });
92
92
 
@@ -4,8 +4,9 @@ import { createPatch } from 'diff';
4
4
  import { getDocsClient, getDriveClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, NotImplementedError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
- import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
7
+ import { docsJsonToMarkdown, checkMarkdownFidelity } from '../../markdown-transformer/index.js';
8
8
  import { trackRead, getLastReadContent } from '../../readTracker.js';
9
+ import { writeWorkspaceFile } from '../../workspace.js';
9
10
 
10
11
  async function fetchModifiedTime(documentId) {
11
12
  try {
@@ -24,7 +25,10 @@ async function fetchModifiedTime(documentId) {
24
25
  export function register(server) {
25
26
  server.addTool({
26
27
  name: 'readDocument',
27
- description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown). Use format='text' for plain text, or format='json' for the raw document structure. Set diffFromLastRead=true (markdown only) to get a unified diff from your previous read in this session instead of the full content.",
28
+ description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown) and saves it to a local working-copy file (path included in the response). " +
29
+ "PREFERRED EDITING WORKFLOW for large edits: (1) readDocument to get the local file path, (2) edit that file locally, (3) call replaceDocumentWithMarkdown with filePath pointing to it. This avoids inline content truncation and gives you a reviewable working copy before pushing changes. " +
30
+ "If the document contains content markdown cannot represent (images, footnotes), a warning is appended listing what replaceDocumentWithMarkdown would permanently remove — prefer modifyText or appendMarkdown for those documents. " +
31
+ "Use format='text' for plain text, or format='json' for the raw document structure. Set diffFromLastRead=true (markdown only) to get a unified diff from your previous read in this session instead of the full content.",
28
32
  parameters: DocumentIdParameter.extend({
29
33
  format: z
30
34
  .enum(['text', 'json', 'markdown'])
@@ -53,7 +57,7 @@ export function register(server) {
53
57
  const needsTabsContent = !!args.tabId;
54
58
  const fields = args.format === 'json' || args.format === 'markdown'
55
59
  ? '*' // Get everything for structure analysis
56
- : 'body(content(paragraph(elements(textRun(content)))))'; // Just text content
60
+ : 'revisionId,body(content(paragraph(elements(textRun(content)))))'; // Just text content
57
61
  const res = await docs.documents.get({
58
62
  documentId: args.documentId,
59
63
  includeTabsContent: needsTabsContent,
@@ -71,7 +75,12 @@ export function register(server) {
71
75
  if (!targetTab.documentTab) {
72
76
  throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`);
73
77
  }
74
- contentSource = { body: targetTab.documentTab.body };
78
+ // List definitions are scoped to the document tab. Without them,
79
+ // docsJsonToMarkdown cannot distinguish ordered lists from bullets.
80
+ contentSource = {
81
+ body: targetTab.documentTab.body,
82
+ lists: targetTab.documentTab.lists,
83
+ };
75
84
  log.info(`Using content from tab: ${targetTab.tabProperties?.title || 'Untitled'}`);
76
85
  }
77
86
  else {
@@ -82,7 +91,7 @@ export function register(server) {
82
91
  if (args.diffFromLastRead) {
83
92
  log.info('diffFromLastRead ignored: only supported for format=markdown');
84
93
  }
85
- trackRead(args.documentId, modifiedTime);
94
+ trackRead(args.documentId, modifiedTime, undefined, res.data.revisionId);
86
95
  const jsonContent = JSON.stringify(contentSource, null, 2);
87
96
  // Apply length limit to JSON if specified
88
97
  if (args.maxLength && jsonContent.length > args.maxLength) {
@@ -95,6 +104,18 @@ export function register(server) {
95
104
  const markdownContent = docsJsonToMarkdown(contentSource);
96
105
  const totalLength = markdownContent.length;
97
106
  log.info(`Generated markdown: ${totalLength} characters`);
107
+ // Derive fidelity warnings from EXACTLY the body that will be replaced
108
+ // (contentSource.body is the active tab's body in tab mode, the document
109
+ // body otherwise). This scopes image/footnote detection to the content
110
+ // the replacement mutates and never over-reports images or footnotes
111
+ // living in other tabs or in headers/footers a body replacement leaves
112
+ // untouched.
113
+ const fidelityWarnings = checkMarkdownFidelity(contentSource.body?.content);
114
+ const fidelityNotice = fidelityWarnings.length > 0
115
+ ? '\n\n---\n⚠️ FORMATTING LOSS WARNING: This document contains content that cannot be represented in markdown. Calling replaceDocumentWithMarkdown will permanently lose:\n' +
116
+ fidelityWarnings.map(w => ` • ${w}`).join('\n') +
117
+ '\nConsider using modifyText or appendMarkdown for targeted edits instead.\n---'
118
+ : '';
98
119
  if (args.diffFromLastRead) {
99
120
  const previous = getLastReadContent(args.documentId);
100
121
  if (previous !== null) {
@@ -106,18 +127,57 @@ export function register(server) {
106
127
  'current',
107
128
  { context: 3 }
108
129
  );
109
- trackRead(args.documentId, modifiedTime, markdownContent);
110
- return patch;
130
+ trackRead(args.documentId, modifiedTime, markdownContent, res.data.revisionId);
131
+ // Keep the on-disk working copy in sync even on diff reads, so a
132
+ // subsequent edit-and-push starts from the current document state.
133
+ try {
134
+ await writeWorkspaceFile(args.documentId, markdownContent, args.tabId);
135
+ } catch (e) {
136
+ log.info(`Could not update workspace on diff read: ${e.message}`);
137
+ }
138
+ // The diff can be silent about content the converter has no
139
+ // representation for: an image or footnote another editor added
140
+ // since the last read simply is not in either markdown snapshot,
141
+ // so it never shows up as a change. The workspace file was still
142
+ // refreshed above, and pushing it back would delete that content.
143
+ // Carry the same warning the full read gives.
144
+ return patch + fidelityNotice;
111
145
  }
112
146
  log.info('diffFromLastRead requested but no prior snapshot exists; returning full content');
113
147
  }
114
- trackRead(args.documentId, modifiedTime, markdownContent);
148
+ // Store clean markdown (without warning) for future diffs and guardMutation
149
+ trackRead(args.documentId, modifiedTime, markdownContent, res.data.revisionId);
150
+ // Save to local workspace file so the AI can edit it and push with filePath.
151
+ // Scoped by tabId so two tabs of the same document keep separate copies.
152
+ let localPath = null;
153
+ try {
154
+ localPath = await writeWorkspaceFile(args.documentId, markdownContent, args.tabId);
155
+ log.info(`Saved to ${localPath}`);
156
+ } catch (e) {
157
+ log.info(`Could not save to workspace: ${e.message}`);
158
+ localPath = null;
159
+ }
115
160
  // Apply length limit to markdown if specified
161
+ let output;
116
162
  if (args.maxLength && totalLength > args.maxLength) {
117
163
  const truncatedContent = markdownContent.substring(0, args.maxLength);
118
- return `${truncatedContent}\n\n... [Markdown truncated to ${args.maxLength} chars of ${totalLength} total. Use maxLength parameter to adjust limit or remove it to get full content.]`;
164
+ output = `${truncatedContent}\n\n... [Markdown truncated to ${args.maxLength} chars of ${totalLength} total. Use maxLength parameter to adjust limit or remove it to get full content.]`;
165
+ } else {
166
+ output = markdownContent;
167
+ }
168
+ // Append fidelity warning after the markdown so the AI knows what
169
+ // replaceDocumentWithMarkdown would permanently destroy.
170
+ output += fidelityNotice;
171
+ if (localPath) {
172
+ // Use forward slashes in the advice string so the path is valid JSON
173
+ // regardless of OS (backslashes in Windows paths break JSON encoding).
174
+ const jsonSafePath = localPath.replace(/\\/g, '/');
175
+ // If this was a tab read, the file holds only that tab's content, so the
176
+ // push must target the same tab to avoid writing it into the wrong tab.
177
+ const tabAdvice = args.tabId ? ` tabId="${args.tabId}"` : '';
178
+ output += `\n\n📄 Local file: ${localPath}\nEdit this file, then call replaceDocumentWithMarkdown with filePath="${jsonSafePath}"${tabAdvice} to push changes.`;
119
179
  }
120
- return markdownContent;
180
+ return output;
121
181
  }
122
182
  // Default: Text format - extract all text content
123
183
  if (args.diffFromLastRead) {
@@ -151,7 +211,7 @@ export function register(server) {
151
211
  });
152
212
  }
153
213
  });
154
- trackRead(args.documentId, modifiedTime);
214
+ trackRead(args.documentId, modifiedTime, undefined, res.data.revisionId);
155
215
  if (!textContent.trim())
156
216
  return 'Document found, but appears empty.';
157
217
  const totalLength = textContent.length;
@@ -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: 'renameTab',
@@ -28,22 +29,19 @@ export function register(server) {
28
29
  throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
29
30
  }
30
31
  const oldTitle = targetTab.tabProperties?.title || '(untitled)';
31
- await docs.documents.batchUpdate({
32
- documentId: args.documentId,
33
- requestBody: {
34
- requests: [
35
- {
36
- updateDocumentTabProperties: {
37
- tabProperties: {
38
- tabId: args.tabId,
39
- title: args.newTitle,
40
- },
41
- fields: 'title',
42
- },
32
+ const revisionId = getLastReadRevisionId(args.documentId);
33
+ const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
34
+ {
35
+ updateDocumentTabProperties: {
36
+ tabProperties: {
37
+ tabId: args.tabId,
38
+ title: args.newTitle,
43
39
  },
44
- ],
40
+ fields: 'title',
41
+ },
45
42
  },
46
- });
43
+ ], revisionId ? { requiredRevisionId: revisionId } : undefined);
44
+ trackMutation(args.documentId, response?.writeControl?.requiredRevisionId);
47
45
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
48
46
  return `${docUrl}\nSuccessfully renamed tab from "${oldTitle}" to "${args.newTitle}".`;
49
47
  }