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,52 +1,52 @@
1
- // Precompute tools/list payload to avoid repeated toJsonSchema() calls.
2
- // Supports rebuilding the cache when new tools are dynamically loaded.
3
- import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
- import { toJsonSchema } from 'xsschema';
5
- import { logger } from './logger.js';
6
-
7
- export function collectToolsWhileRegistering(server, out) {
8
- const add = server.addTool.bind(server);
9
- server.addTool = (tool) => {
10
- out.push(tool);
11
- add(tool);
12
- };
13
- }
14
-
15
- export async function buildCachedToolsListPayload(tools) {
16
- return {
17
- tools: await Promise.all(tools.map(async (tool) => ({
18
- annotations: tool.annotations,
19
- description: tool.description,
20
- inputSchema: tool.parameters
21
- ? await toJsonSchema(tool.parameters)
22
- : {
23
- additionalProperties: false,
24
- properties: {},
25
- type: 'object',
26
- },
27
- name: tool.name,
28
- }))),
29
- };
30
- }
31
-
32
- export function installCachedToolsListHandler(server, registeredTools) {
33
- const session = server.sessions[0];
34
- if (!session) {
35
- logger.warn('No MCP session; skipping tools/list cache install.');
36
- return;
37
- }
38
-
39
- // Build the initial cache from whatever tools are registered at startup
40
- let cachedPayload = null;
41
-
42
- session.server.setRequestHandler(ListToolsRequestSchema, async () => {
43
- // Rebuild cache when tool count changes (new tools dynamically loaded)
44
- if (!cachedPayload || cachedPayload.tools.length !== registeredTools.length) {
45
- logger.debug(`Rebuilding tools/list cache (${registeredTools.length} tools)...`);
46
- cachedPayload = await buildCachedToolsListPayload(registeredTools);
47
- }
48
- return cachedPayload;
49
- });
50
-
51
- logger.debug(`Installed dynamic tools/list cache handler (${registeredTools.length} tools initially).`);
52
- }
1
+ // Precompute tools/list payload to avoid repeated toJsonSchema() calls.
2
+ // Supports rebuilding the cache when new tools are dynamically loaded.
3
+ import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
4
+ import { toJsonSchema } from 'xsschema';
5
+ import { logger } from './logger.js';
6
+
7
+ export function collectToolsWhileRegistering(server, out) {
8
+ const add = server.addTool.bind(server);
9
+ server.addTool = (tool) => {
10
+ out.push(tool);
11
+ add(tool);
12
+ };
13
+ }
14
+
15
+ export async function buildCachedToolsListPayload(tools) {
16
+ return {
17
+ tools: await Promise.all(tools.map(async (tool) => ({
18
+ annotations: tool.annotations,
19
+ description: tool.description,
20
+ inputSchema: tool.parameters
21
+ ? await toJsonSchema(tool.parameters)
22
+ : {
23
+ additionalProperties: false,
24
+ properties: {},
25
+ type: 'object',
26
+ },
27
+ name: tool.name,
28
+ }))),
29
+ };
30
+ }
31
+
32
+ export function installCachedToolsListHandler(server, registeredTools) {
33
+ const session = server.sessions[0];
34
+ if (!session) {
35
+ logger.warn('No MCP session; skipping tools/list cache install.');
36
+ return;
37
+ }
38
+
39
+ // Build the initial cache from whatever tools are registered at startup
40
+ let cachedPayload = null;
41
+
42
+ session.server.setRequestHandler(ListToolsRequestSchema, async () => {
43
+ // Rebuild cache when tool count changes (new tools dynamically loaded)
44
+ if (!cachedPayload || cachedPayload.tools.length !== registeredTools.length) {
45
+ logger.debug(`Rebuilding tools/list cache (${registeredTools.length} tools)...`);
46
+ cachedPayload = await buildCachedToolsListPayload(registeredTools);
47
+ }
48
+ return cachedPayload;
49
+ });
50
+
51
+ logger.debug(`Installed dynamic tools/list cache handler (${registeredTools.length} tools initially).`);
52
+ }
@@ -4,7 +4,7 @@ import { logger } from './logger.js';
4
4
  // --- Constants ---
5
5
  const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
6
6
  // --- Core Helper to Execute Batch Updates ---
7
- export async function executeBatchUpdate(docs, documentId, requests) {
7
+ export async function executeBatchUpdate(docs, documentId, requests, writeControl) {
8
8
  if (!requests || requests.length === 0) {
9
9
  // console.warn("executeBatchUpdate called with no requests.");
10
10
  return {}; // Nothing to do
@@ -16,13 +16,25 @@ export async function executeBatchUpdate(docs, documentId, requests) {
16
16
  try {
17
17
  const response = await docs.documents.batchUpdate({
18
18
  documentId: documentId,
19
- requestBody: { requests },
19
+ requestBody: { requests, ...(writeControl && { writeControl }) },
20
20
  });
21
21
  return response.data;
22
22
  }
23
23
  catch (error) {
24
24
  logger.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
25
25
  // Translate common API errors to UserErrors
26
+ const apiMessage = error.response?.data?.error?.message || error.message || '';
27
+ const apiStatus = error.response?.data?.error?.status;
28
+ // A write sent with writeControl that fails on the revision is a
29
+ // concurrency conflict. Don't rely on message wording alone —
30
+ // FAILED_PRECONDITION is Google's canonical status for this.
31
+ const isRevisionConflict = writeControl && (
32
+ apiStatus === 'FAILED_PRECONDITION' ||
33
+ ((error.code === 400 || error.code === 409) && /revision|write\s*control|updated since/i.test(apiMessage))
34
+ );
35
+ if (isRevisionConflict) {
36
+ throw new UserError(`This document (${documentId}) changed since you last read it. Read the document again before editing to ensure you have current content.`);
37
+ }
26
38
  if (error.code === 400 && error.message.includes('Invalid requests')) {
27
39
  // Try to extract more specific info if available
28
40
  const details = error.response?.data?.error?.details;
@@ -40,6 +52,40 @@ export async function executeBatchUpdate(docs, documentId, requests) {
40
52
  throw new Error(`Google API Error (${error.code}): ${error.message}`);
41
53
  }
42
54
  }
55
+ /**
56
+ * Creates a small stateful helper for chaining an optimistic-concurrency guard
57
+ * across a sequence of writes that make up a single logical operation (e.g.
58
+ * delete -> cleanup -> insert). The first write carries the revision from the
59
+ * caller's last read; each subsequent write must require the revision the
60
+ * previous write produced (returned as `writeControl` on a successful
61
+ * batchUpdate response), so a collaborator edit landing between any two of
62
+ * our own batches is rejected as a conflict instead of silently applied
63
+ * against (PR #42 review).
64
+ *
65
+ * Guarding is opt-in: when `revisionId` is null/undefined (a legacy read that
66
+ * never captured a revision), `current` stays undefined for the life of the
67
+ * chain and `advance` is a no-op, so the flow remains unguarded.
68
+ *
69
+ * @param revisionId - The revisionId from the caller's last tracked read, or null/undefined
70
+ * @returns { get current(), advance(response) }
71
+ */
72
+ export function createWriteControlChain(revisionId) {
73
+ let pendingWriteControl = revisionId ? { requiredRevisionId: revisionId } : undefined;
74
+ return {
75
+ get current() {
76
+ return pendingWriteControl;
77
+ },
78
+ // Advance the chain to the revision produced by a successful write.
79
+ // Only advances when the chain is armed and the response carried a new
80
+ // writeControl — a best-effort write that fails (and is swallowed by the
81
+ // caller) must NOT advance the chain, since the document was not modified.
82
+ advance(response) {
83
+ if (pendingWriteControl && response?.writeControl) {
84
+ pendingWriteControl = response.writeControl;
85
+ }
86
+ },
87
+ };
88
+ }
43
89
  /**
44
90
  * Executes batch updates with automatic splitting for large request arrays.
45
91
  * Separates insert and format operations, executing inserts first.
@@ -50,7 +96,7 @@ export async function executeBatchUpdate(docs, documentId, requests) {
50
96
  * @param log - Optional logger for progress tracking
51
97
  * @returns Metadata about the execution (request counts, API calls, timing)
52
98
  */
53
- export async function executeBatchUpdateWithSplitting(docs, documentId, requests, log) {
99
+ export async function executeBatchUpdateWithSplitting(docs, documentId, requests, log, writeControl) {
54
100
  const overallStart = performance.now();
55
101
  if (!requests || requests.length === 0) {
56
102
  return {
@@ -80,6 +126,21 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
80
126
  'insertInlineImage' in r ||
81
127
  'insertSectionBreak' in r));
82
128
  let totalApiCalls = 0;
129
+ // Chain the optimistic-concurrency guard across every batch this operation
130
+ // sends. When markdown is split into delete/insert/format phases (or a phase
131
+ // exceeds 50 requests), each successful batchUpdate returns the document's new
132
+ // head revision in its writeControl. Requiring that revision on the next batch
133
+ // means a collaborator edit landing between our batches is rejected as a
134
+ // conflict instead of having our precomputed ranges applied to their content
135
+ // (PR #42 review). Only chain when we started guarded, so legacy flows that
136
+ // never captured a revision stay unguarded.
137
+ let chainedWriteControl = writeControl;
138
+ const executeBatch = async (batch) => {
139
+ const data = await executeBatchUpdate(docs, documentId, batch, chainedWriteControl);
140
+ if (chainedWriteControl && data?.writeControl) {
141
+ chainedWriteControl = data.writeControl;
142
+ }
143
+ };
83
144
  // Execute delete batches first (must happen before inserts)
84
145
  const deleteStart = performance.now();
85
146
  if (deleteRequests.length > 0) {
@@ -91,7 +152,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
91
152
  if (log) {
92
153
  log.info(`Delete batch content: ${JSON.stringify(batch)}`);
93
154
  }
94
- await executeBatchUpdate(docs, documentId, batch);
155
+ await executeBatch(batch);
95
156
  totalApiCalls++;
96
157
  if (log) {
97
158
  const batchNum = Math.floor(i / MAX_BATCH) + 1;
@@ -109,7 +170,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
109
170
  if (insertRequests.length > 0) {
110
171
  for (let i = 0; i < insertRequests.length; i += MAX_BATCH) {
111
172
  const batch = insertRequests.slice(i, i + MAX_BATCH);
112
- await executeBatchUpdate(docs, documentId, batch);
173
+ await executeBatch(batch);
113
174
  totalApiCalls++;
114
175
  if (log) {
115
176
  const batchNum = Math.floor(i / MAX_BATCH) + 1;
@@ -124,7 +185,7 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
124
185
  if (formatRequests.length > 0) {
125
186
  for (let i = 0; i < formatRequests.length; i += MAX_BATCH) {
126
187
  const batch = formatRequests.slice(i, i + MAX_BATCH);
127
- await executeBatchUpdate(docs, documentId, batch);
188
+ await executeBatch(batch);
128
189
  totalApiCalls++;
129
190
  if (log) {
130
191
  const batchNum = Math.floor(i / MAX_BATCH) + 1;
@@ -156,6 +217,9 @@ export async function executeBatchUpdateWithSplitting(docs, documentId, requests
156
217
  },
157
218
  totalApiCalls,
158
219
  totalElapsedMs: Math.round(totalElapsedMs),
220
+ // The revision guard after the last batch, so a caller that writes again
221
+ // after this call can keep the chain intact.
222
+ finalWriteControl: chainedWriteControl,
159
223
  };
160
224
  }
161
225
  // --- Text Finding Helper ---
@@ -676,7 +740,7 @@ export function buildUpdateParagraphStyleRequest(startIndex, endIndex, style, ta
676
740
  return { request, fields: fieldsToUpdate };
677
741
  }
678
742
  // --- Specific Feature Helpers ---
679
- export async function createTable(docs, documentId, rows, columns, index, tabId) {
743
+ export async function createTable(docs, documentId, rows, columns, index, tabId, writeControl) {
680
744
  if (rows < 1 || columns < 1) {
681
745
  throw new UserError('Table must have at least 1 row and 1 column.');
682
746
  }
@@ -691,7 +755,7 @@ export async function createTable(docs, documentId, rows, columns, index, tabId)
691
755
  columns: columns,
692
756
  },
693
757
  };
694
- return executeBatchUpdate(docs, documentId, [request]);
758
+ return executeBatchUpdate(docs, documentId, [request], writeControl);
695
759
  }
696
760
  export async function insertText(docs, documentId, text, index) {
697
761
  if (!text)
@@ -824,7 +888,7 @@ export async function addCommentHelper(docs, documentId, text, startIndex, endIn
824
888
  * @param height - Optional height in points
825
889
  * @returns Promise with batch update response
826
890
  */
827
- export async function insertInlineImage(docs, documentId, imageUrl, index, width, height, tabId) {
891
+ export async function insertInlineImage(docs, documentId, imageUrl, index, width, height, tabId, writeControl) {
828
892
  // Validate URL format
829
893
  try {
830
894
  new URL(imageUrl);
@@ -850,7 +914,7 @@ export async function insertInlineImage(docs, documentId, imageUrl, index, width
850
914
  }),
851
915
  },
852
916
  };
853
- return executeBatchUpdate(docs, documentId, [request]);
917
+ return executeBatchUpdate(docs, documentId, [request], writeControl);
854
918
  }
855
919
  /**
856
920
  * Uploads a local image file to Google Drive.
@@ -933,14 +997,14 @@ localFilePath, parentFolderId, skipPublicSharing = false) {
933
997
  * with the actual image blob from Drive (no public sharing needed).
934
998
  */
935
999
  export async function insertImageViaAppsScript(docs, scriptClient, // script_v1.Script type
936
- deploymentId, documentId, driveFileId, charIndex, tabId) {
1000
+ deploymentId, documentId, driveFileId, charIndex, tabId, writeControl) {
937
1001
  const marker = `[mcp-img-${driveFileId}]`;
938
1002
  // Step 1: Insert marker at the requested position via Docs API
939
1003
  const location = { index: charIndex };
940
1004
  if (tabId) {
941
1005
  location.tabId = tabId;
942
1006
  }
943
- await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }]);
1007
+ await executeBatchUpdate(docs, documentId, [{ insertText: { location, text: marker } }], writeControl);
944
1008
  // Step 2: Call Apps Script to replace the marker with the image
945
1009
  const response = await scriptClient.scripts.run({
946
1010
  scriptId: deploymentId,