google-tools-mcp 1.2.12 → 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.
- package/LICENSE +21 -21
- package/README.md +537 -276
- package/dist/cachedToolsList.js +52 -52
- package/dist/googleDocsApiHelpers.js +76 -12
- package/dist/helpers.js +572 -252
- package/dist/httpAuth.js +296 -0
- package/dist/index.js +162 -11
- package/dist/logger.js +87 -87
- package/dist/markdown-transformer/docsToMarkdown.js +128 -0
- package/dist/markdown-transformer/index.js +17 -7
- package/dist/markdown-transformer/markdownToDocs.js +185 -27
- package/dist/readTracker.js +136 -17
- package/dist/sessionContext.js +37 -0
- package/dist/setup.js +187 -9
- package/dist/tools/calendar/getBusy.js +64 -64
- package/dist/tools/calendar/getEvents.js +141 -141
- package/dist/tools/calendar/getFree.js +225 -225
- package/dist/tools/calendar/index.js +19 -19
- package/dist/tools/calendar/listCalendars.js +38 -38
- package/dist/tools/calendar/listRecurringInstances.js +83 -83
- package/dist/tools/calendar/manageCalendar.js +121 -121
- package/dist/tools/calendar/manageEvent.js +258 -258
- package/dist/tools/calendar/moveEvent.js +66 -66
- package/dist/tools/docs/addTab.js +10 -12
- package/dist/tools/docs/appendToGoogleDoc.js +9 -4
- package/dist/tools/docs/deleteRange.js +9 -4
- package/dist/tools/docs/findAndReplace.js +9 -4
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -1
- package/dist/tools/docs/formatting/getFormatting.js +171 -171
- package/dist/tools/docs/insertImage.js +15 -2
- package/dist/tools/docs/insertPageBreak.js +4 -1
- package/dist/tools/docs/insertTable.js +4 -1
- package/dist/tools/docs/insertTableWithData.js +4 -1
- package/dist/tools/docs/modifyText.js +14 -4
- package/dist/tools/docs/modifyText.test.js +84 -84
- package/dist/tools/docs/readGoogleDoc.js +71 -11
- package/dist/tools/docs/renameTab.js +12 -14
- package/dist/tools/drafts.js +165 -165
- package/dist/tools/drive/createDocument.js +12 -0
- package/dist/tools/drive/downloadFile.js +268 -268
- package/dist/tools/drive/getFileInfo.js +48 -48
- package/dist/tools/drive/getFilePath.js +50 -50
- package/dist/tools/drive/listDriveFiles.js +112 -112
- package/dist/tools/drive/listSharedDrives.js +52 -52
- package/dist/tools/drive/listSharedWithMe.js +97 -97
- package/dist/tools/drive/uploadFile.js +111 -111
- package/dist/tools/extras/index.js +9 -9
- package/dist/tools/extras/readDriveFile.js +197 -196
- package/dist/tools/extras/readFile.js +84 -84
- package/dist/tools/extras/searchFileContents.js +81 -81
- package/dist/tools/forms/batchUpdateForm.js +81 -81
- package/dist/tools/forms/createForm.js +54 -54
- package/dist/tools/forms/getForm.js +118 -118
- package/dist/tools/forms/getFormResponse.js +45 -45
- package/dist/tools/forms/index.js +15 -15
- package/dist/tools/forms/listFormResponses.js +56 -56
- package/dist/tools/forms/setPublishSettings.js +59 -59
- package/dist/tools/gmail/drafts.js +165 -165
- package/dist/tools/gmail/labels.js +54 -83
- package/dist/tools/gmail/messages.js +444 -441
- package/dist/tools/gmail/settings.js +358 -528
- package/dist/tools/gmail/threads.js +286 -164
- package/dist/tools/index.js +511 -473
- package/dist/tools/labels.js +103 -103
- package/dist/tools/legacyAliases.js +426 -0
- package/dist/tools/maps/directions.js +72 -0
- package/dist/tools/maps/geocode.js +16 -0
- package/dist/tools/maps/index.js +15 -0
- package/dist/tools/maps/mapsClient.js +83 -0
- package/dist/tools/maps/placeDetails.js +16 -0
- package/dist/tools/maps/reverseGeocode.js +19 -0
- package/dist/tools/maps/searchNearby.js +54 -0
- package/dist/tools/maps/searchPlaces.js +24 -0
- package/dist/tools/messages.js +448 -448
- package/dist/tools/settings.js +528 -528
- package/dist/tools/slides/createPresentation.js +104 -104
- package/dist/tools/slides/createShape.js +92 -92
- package/dist/tools/slides/createTextBox.js +82 -82
- package/dist/tools/slides/deleteSlide.js +30 -30
- package/dist/tools/slides/duplicateSlide.js +37 -37
- package/dist/tools/slides/exportThumbnail.js +42 -42
- package/dist/tools/slides/formatParagraph.js +72 -72
- package/dist/tools/slides/formatText.js +84 -84
- package/dist/tools/slides/getPresentation.js +87 -87
- package/dist/tools/slides/index.js +33 -33
- package/dist/tools/slides/reorderSlides.js +41 -41
- package/dist/tools/slides/replaceAllText.js +46 -46
- package/dist/tools/slides/setBackground.js +58 -58
- package/dist/tools/slides/speakerNotes.js +102 -102
- package/dist/tools/slides/styleShape.js +111 -111
- package/dist/tools/slides/updatePresentation.js +128 -128
- package/dist/tools/threads.js +145 -145
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +30 -6
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +71 -8
- package/dist/updateCheck.js +202 -0
- package/dist/workspace.js +122 -0
- package/package.json +82 -81
package/dist/tools/threads.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
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('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|