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
@@ -1,145 +1,145 @@
1
- // Gmail Thread tools
2
- import { z } from 'zod';
3
- import { getGmailClient } from '../clients.js';
4
- import { processMessagePart } from '../helpers.js';
5
-
6
- export function register(server) {
7
- server.addTool({
8
- name: 'get_thread',
9
- description: 'Get a specific thread by ID',
10
- parameters: z.object({
11
- id: z.string().describe("The ID of the thread to retrieve"),
12
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
13
- }),
14
- execute: async (params) => {
15
- const gmail = await getGmailClient();
16
- const { data } = await gmail.users.threads.get({ userId: 'me', id: params.id, format: 'full' });
17
- if (data.messages) {
18
- data.messages = data.messages.map(message => {
19
- if (message.payload) {
20
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
21
- }
22
- return message;
23
- });
24
- }
25
- return JSON.stringify(data);
26
- },
27
- });
28
-
29
- server.addTool({
30
- name: 'list_threads',
31
- description: 'List threads in the user\'s mailbox',
32
- parameters: z.object({
33
- maxResults: z.number().optional().describe("Maximum number of threads to return"),
34
- pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
35
- q: z.string().optional().describe("Only return threads matching the specified query"),
36
- labelIds: z.array(z.string()).optional().describe("Only return threads with labels that match all specified label IDs"),
37
- includeSpamTrash: z.boolean().optional().describe("Include threads from SPAM and TRASH"),
38
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
39
- }),
40
- execute: async (params) => {
41
- const gmail = await getGmailClient();
42
- const { data } = await gmail.users.threads.list({ userId: 'me', ...params });
43
- if (data.threads) {
44
- data.threads = data.threads.map(thread => {
45
- if (thread.messages) {
46
- thread.messages = thread.messages.map(message => {
47
- if (message.payload) {
48
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
49
- }
50
- return message;
51
- });
52
- }
53
- return thread;
54
- });
55
- }
56
- return JSON.stringify(data);
57
- },
58
- });
59
-
60
- server.addTool({
61
- name: 'batch_get_threads',
62
- description: 'Get multiple threads by ID in parallel. More efficient than calling get_thread multiple times.',
63
- parameters: z.object({
64
- ids: z.array(z.string()).describe("The IDs of the threads to retrieve"),
65
- includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
66
- }),
67
- execute: async (params) => {
68
- const gmail = await getGmailClient();
69
- const results = await Promise.all(
70
- params.ids.map(async (id) => {
71
- try {
72
- const { data } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
73
- if (data.messages) {
74
- data.messages = data.messages.map(message => {
75
- if (message.payload) {
76
- message.payload = processMessagePart(message.payload, params.includeBodyHtml);
77
- }
78
- return message;
79
- });
80
- }
81
- return data;
82
- } catch (error) {
83
- return { id, error: error.message || 'Failed to retrieve thread' };
84
- }
85
- })
86
- );
87
- return JSON.stringify(results);
88
- },
89
- });
90
-
91
- server.addTool({
92
- name: 'modify_thread',
93
- description: 'Modify the labels applied to a thread',
94
- parameters: z.object({
95
- id: z.string().describe("The ID of the thread to modify"),
96
- addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
97
- removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
98
- }),
99
- execute: async (params) => {
100
- const { id, ...threadData } = params;
101
- const gmail = await getGmailClient();
102
- const { data } = await gmail.users.threads.modify({ userId: 'me', id, requestBody: threadData });
103
- return JSON.stringify(data);
104
- },
105
- });
106
-
107
- server.addTool({
108
- name: 'delete_thread',
109
- description: 'Delete a thread',
110
- parameters: z.object({
111
- id: z.string().describe("The ID of the thread to delete"),
112
- }),
113
- execute: async (params) => {
114
- const gmail = await getGmailClient();
115
- const { data } = await gmail.users.threads.delete({ userId: 'me', id: params.id });
116
- return JSON.stringify(data || { success: true });
117
- },
118
- });
119
-
120
- server.addTool({
121
- name: 'trash_thread',
122
- description: 'Move a thread to the trash',
123
- parameters: z.object({
124
- id: z.string().describe("The ID of the thread to move to trash"),
125
- }),
126
- execute: async (params) => {
127
- const gmail = await getGmailClient();
128
- const { data } = await gmail.users.threads.trash({ userId: 'me', id: params.id });
129
- return JSON.stringify(data);
130
- },
131
- });
132
-
133
- server.addTool({
134
- name: 'untrash_thread',
135
- description: 'Remove a thread from the trash',
136
- parameters: z.object({
137
- id: z.string().describe("The ID of the thread to remove from trash"),
138
- }),
139
- execute: async (params) => {
140
- const gmail = await getGmailClient();
141
- const { data } = await gmail.users.threads.untrash({ userId: 'me', id: params.id });
142
- return JSON.stringify(data);
143
- },
144
- });
145
- }
1
+ // Gmail Thread tools
2
+ import { z } from 'zod';
3
+ import { getGmailClient } from '../clients.js';
4
+ import { processMessagePart } from '../helpers.js';
5
+
6
+ export function register(server) {
7
+ server.addTool({
8
+ name: 'get_thread',
9
+ description: 'Get a specific thread by ID',
10
+ parameters: z.object({
11
+ id: z.string().describe("The ID of the thread to retrieve"),
12
+ includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
13
+ }),
14
+ execute: async (params) => {
15
+ const gmail = await getGmailClient();
16
+ const { data } = await gmail.users.threads.get({ userId: 'me', id: params.id, format: 'full' });
17
+ if (data.messages) {
18
+ data.messages = data.messages.map(message => {
19
+ if (message.payload) {
20
+ message.payload = processMessagePart(message.payload, params.includeBodyHtml);
21
+ }
22
+ return message;
23
+ });
24
+ }
25
+ return JSON.stringify(data);
26
+ },
27
+ });
28
+
29
+ server.addTool({
30
+ name: 'list_threads',
31
+ description: 'List threads in the user\'s mailbox',
32
+ parameters: z.object({
33
+ maxResults: z.number().optional().describe("Maximum number of threads to return"),
34
+ pageToken: z.string().optional().describe("Page token to retrieve a specific page of results"),
35
+ q: z.string().optional().describe("Only return threads matching the specified query"),
36
+ labelIds: z.array(z.string()).optional().describe("Only return threads with labels that match all specified label IDs"),
37
+ includeSpamTrash: z.boolean().optional().describe("Include threads from SPAM and TRASH"),
38
+ includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
39
+ }),
40
+ execute: async (params) => {
41
+ const gmail = await getGmailClient();
42
+ const { data } = await gmail.users.threads.list({ userId: 'me', ...params });
43
+ if (data.threads) {
44
+ data.threads = data.threads.map(thread => {
45
+ if (thread.messages) {
46
+ thread.messages = thread.messages.map(message => {
47
+ if (message.payload) {
48
+ message.payload = processMessagePart(message.payload, params.includeBodyHtml);
49
+ }
50
+ return message;
51
+ });
52
+ }
53
+ return thread;
54
+ });
55
+ }
56
+ return JSON.stringify(data);
57
+ },
58
+ });
59
+
60
+ server.addTool({
61
+ name: 'batch_get_threads',
62
+ description: 'Get multiple threads by ID in parallel. More efficient than calling get_thread multiple times.',
63
+ parameters: z.object({
64
+ ids: z.array(z.string()).describe("The IDs of the threads to retrieve"),
65
+ includeBodyHtml: z.boolean().optional().describe("Whether to include the parsed HTML in the return for each body"),
66
+ }),
67
+ execute: async (params) => {
68
+ const gmail = await getGmailClient();
69
+ const results = await Promise.all(
70
+ params.ids.map(async (id) => {
71
+ try {
72
+ const { data } = await gmail.users.threads.get({ userId: 'me', id, format: 'full' });
73
+ if (data.messages) {
74
+ data.messages = data.messages.map(message => {
75
+ if (message.payload) {
76
+ message.payload = processMessagePart(message.payload, params.includeBodyHtml);
77
+ }
78
+ return message;
79
+ });
80
+ }
81
+ return data;
82
+ } catch (error) {
83
+ return { id, error: error.message || 'Failed to retrieve thread' };
84
+ }
85
+ })
86
+ );
87
+ return JSON.stringify(results);
88
+ },
89
+ });
90
+
91
+ server.addTool({
92
+ name: 'modify_thread',
93
+ description: 'Modify the labels applied to a thread',
94
+ parameters: z.object({
95
+ id: z.string().describe("The ID of the thread to modify"),
96
+ addLabelIds: z.array(z.string()).optional().describe("Label IDs to add"),
97
+ removeLabelIds: z.array(z.string()).optional().describe("Label IDs to remove"),
98
+ }),
99
+ execute: async (params) => {
100
+ const { id, ...threadData } = params;
101
+ const gmail = await getGmailClient();
102
+ const { data } = await gmail.users.threads.modify({ userId: 'me', id, requestBody: threadData });
103
+ return JSON.stringify(data);
104
+ },
105
+ });
106
+
107
+ server.addTool({
108
+ name: 'delete_thread',
109
+ description: 'Delete a thread',
110
+ parameters: z.object({
111
+ id: z.string().describe("The ID of the thread to delete"),
112
+ }),
113
+ execute: async (params) => {
114
+ const gmail = await getGmailClient();
115
+ const { data } = await gmail.users.threads.delete({ userId: 'me', id: params.id });
116
+ return JSON.stringify(data || { success: true });
117
+ },
118
+ });
119
+
120
+ server.addTool({
121
+ name: 'trash_thread',
122
+ description: 'Move a thread to the trash',
123
+ parameters: z.object({
124
+ id: z.string().describe("The ID of the thread to move to trash"),
125
+ }),
126
+ execute: async (params) => {
127
+ const gmail = await getGmailClient();
128
+ const { data } = await gmail.users.threads.trash({ userId: 'me', id: params.id });
129
+ return JSON.stringify(data);
130
+ },
131
+ });
132
+
133
+ server.addTool({
134
+ name: 'untrash_thread',
135
+ description: 'Remove a thread from the trash',
136
+ parameters: z.object({
137
+ id: z.string().describe("The ID of the thread to remove from trash"),
138
+ }),
139
+ execute: async (params) => {
140
+ const gmail = await getGmailClient();
141
+ const { data } = await gmail.users.threads.untrash({ userId: 'me', id: params.id });
142
+ return JSON.stringify(data);
143
+ },
144
+ });
145
+ }
@@ -5,12 +5,13 @@ import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
7
  import { insertMarkdown, formatInsertResult, 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: 'appendMarkdown',
12
12
  description: 'Best for adding new formatted content to the end of a document. ' +
13
- 'Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. ' +
13
+ 'Supports headings, bold, italic, strikethrough, links, tables, bullet/numbered lists, and rich markdown HTML extensions for underline, color, highlight, font, alignment, and blockquotes. ' +
14
+ 'Does not support markdown images or raw HTML outside those listed extensions; unsupported content is omitted and reported as warnings in the result. Use insertImage for images. ' +
14
15
  'Use this instead of appendText when you need formatting. ' +
15
16
  'To edit existing content, use modifyText (single-location) or replaceDocumentWithMarkdown (section/full rewrite).',
16
17
  parameters: DocumentIdParameter.extend({
@@ -36,7 +37,11 @@ export function register(server) {
36
37
  await guardMutation(args.documentId, {
37
38
  contentFetcher: async () => {
38
39
  const current = await docs.documents.get({ documentId: args.documentId });
39
- return docsJsonToMarkdown(current.data);
40
+ // Return the revision this content came from alongside the
41
+ // content itself so guardMutation can refresh both together
42
+ // instead of leaving revisionId stale after a diff (see
43
+ // readTracker.js guardMutation for why that matters).
44
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
40
45
  },
41
46
  });
42
47
  // Resolve markdown content from filePath or inline parameter
@@ -54,6 +59,13 @@ export function register(server) {
54
59
  }
55
60
  log.info(`Appending markdown to doc ${args.documentId} (${markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
56
61
  try {
62
+ const revisionId = getLastReadRevisionId(args.documentId);
63
+ // Optimistic-concurrency guard. The first write carries the revision
64
+ // from our last read; each subsequent write advances to the revision the
65
+ // previous write produced (returned by batchUpdate). This keeps every
66
+ // write in the operation guarded against concurrent edits instead of
67
+ // dropping the guard after the first write (PR #42 review).
68
+ const writeControlChain = GDocsHelpers.createWriteControlChain(revisionId);
57
69
  // 1. Get document end index
58
70
  const doc = await docs.documents.get({
59
71
  documentId: args.documentId,
@@ -85,14 +97,15 @@ export function register(server) {
85
97
  if (args.tabId) {
86
98
  location.tabId = args.tabId;
87
99
  }
88
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
100
+ const spacingResult = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
89
101
  {
90
102
  insertText: {
91
103
  location,
92
104
  text: '\n\n',
93
105
  },
94
106
  },
95
- ]);
107
+ ], writeControlChain.current);
108
+ writeControlChain.advance(spacingResult);
96
109
  startIndex += 2;
97
110
  log.info(`Added spacing, new start index: ${startIndex}`);
98
111
  }
@@ -101,12 +114,23 @@ export function register(server) {
101
114
  startIndex,
102
115
  tabId: args.tabId,
103
116
  firstHeadingAsTitle: args.firstHeadingAsTitle,
117
+ // Carries the current guard; insertMarkdown chains it across its own
118
+ // split batches so the whole insert stays guarded.
119
+ writeControl: writeControlChain.current,
104
120
  });
105
121
  const debugSummary = formatInsertResult(result);
106
122
  log.info(debugSummary);
107
123
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
108
- trackMutation(args.documentId);
109
- return `${docUrl}\nSuccessfully appended ${markdown.length} characters of markdown.\n\n${debugSummary}`;
124
+ // insertMarkdown chains the guard across its own internal split batches
125
+ // and returns the final revision as batchUpdate.finalWriteControl; fold
126
+ // that into our chain so trackMutation re-arms the guard against the
127
+ // TRUE post-write revision instead of the pre-insert (spacing-only) one.
128
+ writeControlChain.advance({ writeControl: result.batchUpdate?.finalWriteControl });
129
+ trackMutation(args.documentId, writeControlChain.current?.requiredRevisionId);
130
+ const warningNote = result.warnings?.length
131
+ ? ` with ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'} (content dropped — see below)`
132
+ : '';
133
+ return `${docUrl}\nSuccessfully appended ${markdown.length} characters of markdown${warningNote}.\n\n${debugSummary}`;
110
134
  }
111
135
  catch (error) {
112
136
  log.error(`Error appending markdown: ${error.message}`);
@@ -5,17 +5,20 @@ import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
7
  import { insertMarkdown, formatInsertResult, docsJsonToMarkdown } from '../../markdown-transformer/index.js';
8
- import { guardMutation, trackMutation } from '../../readTracker.js';
8
+ import { guardMutation, getLastReadRevisionId, trackMutation } from '../../readTracker.js';
9
+ import { writeWorkspaceFile } from '../../workspace.js';
9
10
  export function register(server) {
10
11
  server.addTool({
11
12
  name: 'replaceDocumentWithMarkdown',
12
13
  description: "Best for rewriting entire sections or full documents. Replaces the entire document body with content parsed from markdown. " +
13
- "Supports headings, bold, italic, strikethrough, links, and bullet/numbered lists. " +
14
+ "Supports headings, bold, italic, strikethrough, links, tables, bullet/numbered lists, and rich markdown HTML extensions for underline, color, highlight, font, alignment, and blockquotes. " +
15
+ "Does not support markdown images or raw HTML outside those listed extensions; unsupported content is omitted and reported as warnings in the result. Use insertImage for images. " +
14
16
  "Use readDocument with format='markdown' first to get the current content, edit it, then call this tool to apply changes. " +
17
+ "PREFERRED WORKFLOW for large edits: readDocument saves the content to a local working-copy file and returns its path — edit that file, then pass it here as filePath instead of inline markdown, to avoid truncation and get a reviewable diff before pushing. " +
15
18
  "For small single-location edits (one line or paragraph), use modifyText instead. " +
16
19
  "To add content without rewriting, use appendMarkdown.",
17
20
  parameters: DocumentIdParameter.extend({
18
- markdown: z.string().optional().describe('The markdown content to apply to the document. For content longer than ~2000 characters, prefer writing to a local file first and passing filePath instead.'),
21
+ markdown: z.string().optional().describe('Inline markdown content. Prefer filePath instead for content longer than ~2000 characters use the working-copy path returned by readDocument, edit that file, then pass it here.'),
19
22
  filePath: z.string().optional().describe('Path to a local markdown file to use as content. Takes precedence over the markdown parameter. Use this for large documents to avoid truncation.'),
20
23
  preserveTitle: z
21
24
  .boolean()
@@ -37,7 +40,11 @@ export function register(server) {
37
40
  await guardMutation(args.documentId, {
38
41
  contentFetcher: async () => {
39
42
  const current = await docs.documents.get({ documentId: args.documentId });
40
- return docsJsonToMarkdown(current.data);
43
+ // Return the revision this content came from alongside the
44
+ // content itself so guardMutation can refresh both together
45
+ // instead of leaving revisionId stale after a diff (see
46
+ // readTracker.js guardMutation for why that matters).
47
+ return { content: docsJsonToMarkdown(current.data), revisionId: current.data.revisionId };
41
48
  },
42
49
  });
43
50
  // Resolve markdown content from filePath or inline parameter
@@ -55,6 +62,14 @@ export function register(server) {
55
62
  }
56
63
  log.info(`Replacing doc ${args.documentId} with markdown (${markdown.length} chars)${args.tabId ? ` in tab ${args.tabId}` : ''}`);
57
64
  try {
65
+ const revisionId = getLastReadRevisionId(args.documentId);
66
+ // Optimistic-concurrency guard. The first write carries the revision
67
+ // from our last read; each subsequent write advances to the revision the
68
+ // previous write produced (returned by batchUpdate). This keeps every
69
+ // write in the operation (delete → cleanup → insert) guarded against
70
+ // concurrent edits instead of dropping the guard after the first write
71
+ // (PR #42 review).
72
+ const writeControlChain = GDocsHelpers.createWriteControlChain(revisionId);
58
73
  // 1. Get document structure
59
74
  const doc = await docs.documents.get({
60
75
  documentId: args.documentId,
@@ -97,11 +112,12 @@ export function register(server) {
97
112
  deleteRange.tabId = args.tabId;
98
113
  }
99
114
  log.info(`Deleting content from index ${startIndex} to ${endIndex}`);
100
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
115
+ const deleteResult = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [
101
116
  {
102
117
  deleteContentRange: { range: deleteRange },
103
118
  },
104
- ]);
119
+ ], writeControlChain.current);
120
+ writeControlChain.advance(deleteResult);
105
121
  log.info(`Delete complete.`);
106
122
  }
107
123
  // 4. Clean the surviving trailing paragraph.
@@ -149,11 +165,29 @@ export function register(server) {
149
165
  },
150
166
  },
151
167
  ];
168
+ // This cleanup is the operation's first write when the delete step
169
+ // was skipped (empty document), so it must carry the current guard —
170
+ // otherwise it bumps the revision and the insert below fails with a
171
+ // spurious conflict against the revision from the read. Peek (don't
172
+ // advance yet): the cleanup is best-effort, and only a SUCCESSFUL
173
+ // cleanup changes the revision.
174
+ const cleanupWriteControl = writeControlChain.current;
152
175
  try {
153
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, cleanupRequests);
176
+ const cleanupResult = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, cleanupRequests, cleanupWriteControl);
177
+ // Advance only after success, so the insert requires the revision
178
+ // the cleanup produced.
179
+ writeControlChain.advance(cleanupResult);
154
180
  log.info(`Cleaned surviving paragraph (bullets + text style) at range ${startIndex}-${survivorEnd}`);
155
181
  }
156
182
  catch (e) {
183
+ // A revision conflict is a genuine concurrent edit — surface it
184
+ // instead of proceeding to clobber the document unguarded.
185
+ if (cleanupWriteControl && e instanceof UserError && /changed since you last read/i.test(e.message)) {
186
+ throw e;
187
+ }
188
+ // Non-conflict failure: the cleanup did not modify the document,
189
+ // so the revision is unchanged and writeControlChain.current still
190
+ // guards the insert below (we deliberately did NOT advance it).
157
191
  log.info(`Survivor cleanup skipped: ${e.message}`);
158
192
  }
159
193
  }
@@ -163,12 +197,41 @@ export function register(server) {
163
197
  startIndex,
164
198
  tabId: args.tabId,
165
199
  firstHeadingAsTitle: args.firstHeadingAsTitle,
200
+ // Carries the current guard; insertMarkdown chains it across its own
201
+ // split batches so the whole insert stays guarded.
202
+ writeControl: writeControlChain.current,
166
203
  });
167
204
  const debugSummary = formatInsertResult(result);
168
205
  log.info(debugSummary);
169
- trackMutation(args.documentId);
206
+ // insertMarkdown chains the guard across its own internal split batches
207
+ // and returns the final revision as batchUpdate.finalWriteControl; fold
208
+ // that into our chain so trackMutation re-arms the guard against the
209
+ // TRUE post-write revision instead of the pre-insert (delete/cleanup) one.
210
+ writeControlChain.advance({ writeControl: result.batchUpdate?.finalWriteControl });
211
+ trackMutation(args.documentId, writeControlChain.current?.requiredRevisionId);
212
+ // Mirror the pushed markdown to the local workspace only now that the
213
+ // Docs mutation has actually succeeded and been tracked. Writing this
214
+ // earlier (before the fetch/delete/cleanup/insert sequence above)
215
+ // meant that if any of those steps failed, the local file held content
216
+ // that was never committed to the document; worse, if the delete
217
+ // succeeded but the insert failed, the workspace file would show the
218
+ // full intended result while the document itself was left partial.
219
+ // Scoped by tabId so it lines up with the per-tab file readDocument
220
+ // created. Non-fatal: a failure to save the mirror doesn't undo an
221
+ // already-successful Docs write, so we log and continue.
222
+ if (!args.filePath) {
223
+ try {
224
+ const workspacePath = await writeWorkspaceFile(args.documentId, markdown, args.tabId);
225
+ log.info(`Saved working copy to ${workspacePath}`);
226
+ } catch (e) {
227
+ log.info(`Could not save working copy: ${e.message}`);
228
+ }
229
+ }
170
230
  const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
171
- return `${docUrl}\nSuccessfully replaced document content with ${markdown.length} characters of markdown.\n\n${debugSummary}`;
231
+ const warningNote = result.warnings?.length
232
+ ? ` with ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'} (content dropped — see below)`
233
+ : '';
234
+ return `${docUrl}\nSuccessfully replaced document content with ${markdown.length} characters of markdown${warningNote}.\n\n${debugSummary}`;
172
235
  }
173
236
  catch (error) {
174
237
  log.error(`Error replacing document with markdown: ${error.message}`);