google-tools-mcp 1.0.16 → 1.1.1
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/dist/googleDocsApiHelpers.js +220 -108
- package/dist/markdown-transformer/index.js +23 -3
- package/dist/markdown-transformer/markdownToDocs.js +25 -0
- package/dist/readTracker.js +84 -0
- package/dist/tools/docs/addTab.js +1 -0
- package/dist/tools/docs/appendToGoogleDoc.js +24 -4
- package/dist/tools/docs/comments/addComment.js +2 -1
- package/dist/tools/docs/comments/deleteComment.js +2 -1
- package/dist/tools/docs/comments/replyToComment.js +2 -1
- package/dist/tools/docs/comments/resolveComment.js +3 -2
- package/dist/tools/docs/deleteRange.js +5 -1
- package/dist/tools/docs/findAndReplace.js +5 -1
- package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -3
- package/dist/tools/docs/insertImage.js +4 -2
- package/dist/tools/docs/insertPageBreak.js +2 -1
- package/dist/tools/docs/insertTable.js +2 -1
- package/dist/tools/docs/insertTableWithData.js +2 -1
- package/dist/tools/docs/modifyText.js +27 -11
- package/dist/tools/docs/modifyText.test.js +90 -0
- package/dist/tools/docs/readGoogleDoc.js +2 -0
- package/dist/tools/docs/renameTab.js +2 -1
- package/dist/tools/drive/deleteFile.js +2 -0
- package/dist/tools/drive/index.js +2 -0
- package/dist/tools/drive/moveFile.js +3 -2
- package/dist/tools/drive/uploadFile.js +111 -0
- package/dist/tools/extras/index.js +2 -0
- package/dist/tools/extras/readDriveFile.js +206 -0
- package/dist/tools/extras/readFile.js +2 -0
- package/dist/tools/forms/batchUpdateForm.js +1 -0
- package/dist/tools/forms/setPublishSettings.js +1 -0
- package/dist/tools/index.js +117 -11
- package/dist/tools/sheets/addConditionalFormatting.js +2 -1
- package/dist/tools/sheets/addSpreadsheetSheet.js +2 -1
- package/dist/tools/sheets/appendSpreadsheetRows.js +2 -1
- package/dist/tools/sheets/appendTableRows.js +1 -0
- package/dist/tools/sheets/autoResizeColumns.js +2 -1
- package/dist/tools/sheets/batchWrite.js +4 -1
- package/dist/tools/sheets/clearSpreadsheetRange.js +4 -1
- package/dist/tools/sheets/copyFormatting.js +2 -1
- package/dist/tools/sheets/createTable.js +1 -0
- package/dist/tools/sheets/deleteChart.js +2 -1
- package/dist/tools/sheets/deleteSheet.js +2 -1
- package/dist/tools/sheets/deleteTable.js +1 -0
- package/dist/tools/sheets/duplicateSheet.js +2 -1
- package/dist/tools/sheets/formatCells.js +2 -1
- package/dist/tools/sheets/freezeRowsAndColumns.js +2 -1
- package/dist/tools/sheets/getSpreadsheetInfo.js +2 -0
- package/dist/tools/sheets/groupRows.js +2 -1
- package/dist/tools/sheets/insertChart.js +2 -1
- package/dist/tools/sheets/readSpreadsheet.js +2 -0
- package/dist/tools/sheets/renameSheet.js +2 -1
- package/dist/tools/sheets/setColumnWidths.js +2 -1
- package/dist/tools/sheets/setDropdownValidation.js +3 -2
- package/dist/tools/sheets/ungroupAllRows.js +2 -1
- package/dist/tools/sheets/updateTableRange.js +1 -0
- package/dist/tools/sheets/writeSpreadsheet.js +4 -1
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +27 -5
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +28 -5
- package/dist/types.js +1 -2
- package/package.json +10 -2
|
@@ -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 { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
export function register(server) {
|
|
7
8
|
server.addTool({
|
|
8
9
|
name: 'deleteRange',
|
|
@@ -27,6 +28,7 @@ export function register(server) {
|
|
|
27
28
|
path: ['endIndex'],
|
|
28
29
|
}),
|
|
29
30
|
execute: async (args, { log }) => {
|
|
31
|
+
await guardMutation(args.documentId);
|
|
30
32
|
const docs = await getDocsClient();
|
|
31
33
|
log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
32
34
|
if (args.endIndex <= args.startIndex) {
|
|
@@ -59,7 +61,9 @@ export function register(server) {
|
|
|
59
61
|
deleteContentRange: { range },
|
|
60
62
|
};
|
|
61
63
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
62
|
-
|
|
64
|
+
trackMutation(args.documentId);
|
|
65
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
66
|
+
return `${docUrl}\nSuccessfully deleted content in range ${args.startIndex}-${args.endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
63
67
|
}
|
|
64
68
|
catch (error) {
|
|
65
69
|
log.error(`Error deleting range in doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -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 { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
const FindAndReplaceParameters = DocumentIdParameter.extend({
|
|
7
8
|
findText: z.string().min(1).describe('The text to search for in the document.'),
|
|
8
9
|
replaceText: z
|
|
@@ -24,6 +25,7 @@ export function register(server) {
|
|
|
24
25
|
'Returns the number of replacements made. Use an empty replaceText to delete all matches.',
|
|
25
26
|
parameters: FindAndReplaceParameters,
|
|
26
27
|
execute: async (args, { log }) => {
|
|
28
|
+
await guardMutation(args.documentId);
|
|
27
29
|
const docs = await getDocsClient();
|
|
28
30
|
log.info(`findAndReplace in doc ${args.documentId}: "${args.findText}" → "${args.replaceText}"` +
|
|
29
31
|
`${args.matchCase ? ' (case-sensitive)' : ''}` +
|
|
@@ -40,8 +42,10 @@ export function register(server) {
|
|
|
40
42
|
},
|
|
41
43
|
};
|
|
42
44
|
const response = await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
45
|
+
trackMutation(args.documentId);
|
|
43
46
|
const changed = response.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
|
|
44
|
-
|
|
47
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
48
|
+
return `${docUrl}\nReplaced ${changed} occurrence(s) of "${args.findText}" with "${args.replaceText}".`;
|
|
45
49
|
}
|
|
46
50
|
catch (error) {
|
|
47
51
|
log.error(`Error in findAndReplace for doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -18,8 +18,8 @@ export function register(server) {
|
|
|
18
18
|
// STEP 1: Determine the target paragraph's range based on the targeting method
|
|
19
19
|
if ('textToFind' in args.target) {
|
|
20
20
|
// Find the text first
|
|
21
|
-
log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance
|
|
22
|
-
const textRange = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance
|
|
21
|
+
log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance ?? 'auto'})${args.tabId ? ` in tab ${args.tabId}` : ''}`);
|
|
22
|
+
const textRange = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
|
|
23
23
|
if (!textRange) {
|
|
24
24
|
throw new UserError(`Could not find "${args.target.textToFind}" in the document${args.tabId ? ` (tab: ${args.tabId})` : ''}.`);
|
|
25
25
|
}
|
|
@@ -65,7 +65,8 @@ export function register(server) {
|
|
|
65
65
|
}
|
|
66
66
|
log.info(`Applying styles: ${requestInfo.fields.join(', ')}`);
|
|
67
67
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
|
|
68
|
-
|
|
68
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
69
|
+
return `${docUrl}\nSuccessfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
69
70
|
}
|
|
70
71
|
catch (error) {
|
|
71
72
|
// Detailed error logging
|
|
@@ -76,7 +76,8 @@ export function register(server) {
|
|
|
76
76
|
);
|
|
77
77
|
log.info(`[AppsScript] Inserting image via marker at index ${args.index} (fileId: ${driveFileId})`);
|
|
78
78
|
await GDocsHelpers.insertImageViaAppsScript(docs, scriptClient, appsScriptDeploymentId, args.documentId, driveFileId, args.index, args.tabId);
|
|
79
|
-
|
|
79
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
80
|
+
return `${docUrl}\nSuccessfully inserted local image at index ${args.index} via Apps Script${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
80
81
|
}
|
|
81
82
|
// --- Standard path: public URL insertion via Docs API ---
|
|
82
83
|
let resolvedUrl;
|
|
@@ -109,7 +110,8 @@ export function register(server) {
|
|
|
109
110
|
if (args.width && args.height) {
|
|
110
111
|
sizeInfo = ` with size ${args.width}x${args.height}pt`;
|
|
111
112
|
}
|
|
112
|
-
|
|
113
|
+
const docUrl2 = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
114
|
+
return `${docUrl2}\nSuccessfully inserted image at index ${args.index}${sizeInfo}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
113
115
|
}
|
|
114
116
|
catch (error) {
|
|
115
117
|
log.error(`Error inserting image in doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -45,7 +45,8 @@ export function register(server) {
|
|
|
45
45
|
insertPageBreak: { location },
|
|
46
46
|
};
|
|
47
47
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
|
|
48
|
-
|
|
48
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
49
|
+
return `${docUrl}\nSuccessfully inserted page break at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
49
50
|
}
|
|
50
51
|
catch (error) {
|
|
51
52
|
log.error(`Error inserting page break in doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -40,7 +40,8 @@ export function register(server) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index, args.tabId);
|
|
43
|
-
|
|
43
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
44
|
+
return `${docUrl}\nSuccessfully inserted a ${args.rows}x${args.columns} table at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
44
45
|
}
|
|
45
46
|
catch (error) {
|
|
46
47
|
log.error(`Error inserting table in doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -119,7 +119,8 @@ export function register(server) {
|
|
|
119
119
|
}
|
|
120
120
|
const requests = buildInsertTableWithDataRequests(args.data, args.index, args.hasHeaderRow ?? false, args.tabId);
|
|
121
121
|
const metadata = await GDocsHelpers.executeBatchUpdateWithSplitting(docs, args.documentId, requests, log);
|
|
122
|
-
|
|
122
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
123
|
+
return (`${docUrl}\nSuccessfully inserted a ${numRows}x${numCols} table with data at index ${args.index}` +
|
|
123
124
|
`${args.tabId ? ` in tab ${args.tabId}` : ''}. ` +
|
|
124
125
|
`${args.hasHeaderRow ? 'Header row bolded. ' : ''}` +
|
|
125
126
|
`(${metadata.totalRequests} requests in ${metadata.totalApiCalls} API calls, ${metadata.totalElapsedMs}ms)`);
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getDocsClient } from '../../clients.js';
|
|
4
4
|
import { DocumentIdParameter, TextFindParameter, TextStyleParameters, ParagraphStyleParameters } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
|
+
import { guardMutation, trackMutation } from '../../readTracker.js';
|
|
6
7
|
const RangeTarget = z
|
|
7
8
|
.object({
|
|
8
9
|
startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
|
|
@@ -42,17 +43,17 @@ const ModifyTextParameters = DocumentIdParameter.extend({
|
|
|
42
43
|
export function buildModifyTextRequests(opts) {
|
|
43
44
|
const { startIndex, endIndex, text, style, paragraphStyle, tabId } = opts;
|
|
44
45
|
const requests = [];
|
|
45
|
-
if (
|
|
46
|
+
if (text === undefined && !style && !paragraphStyle)
|
|
46
47
|
return requests;
|
|
47
|
-
// 1. Delete existing content (
|
|
48
|
+
// 1. Delete existing content (when replacing or deleting)
|
|
48
49
|
if (endIndex !== undefined && text !== undefined) {
|
|
49
50
|
const range = { startIndex, endIndex };
|
|
50
51
|
if (tabId)
|
|
51
52
|
range.tabId = tabId;
|
|
52
53
|
requests.push({ deleteContentRange: { range } });
|
|
53
54
|
}
|
|
54
|
-
// 2. Insert new text
|
|
55
|
-
if (text !== undefined) {
|
|
55
|
+
// 2. Insert new text (skip if empty string — that means "delete only")
|
|
56
|
+
if (text !== undefined && text !== '') {
|
|
56
57
|
const location = { index: startIndex };
|
|
57
58
|
if (tabId)
|
|
58
59
|
location.tabId = tabId;
|
|
@@ -93,11 +94,17 @@ export function buildModifyTextRequests(opts) {
|
|
|
93
94
|
export function register(server) {
|
|
94
95
|
server.addTool({
|
|
95
96
|
name: 'modifyText',
|
|
96
|
-
description: '
|
|
97
|
-
'Can insert text at a position, replace a range or found text,
|
|
98
|
-
|
|
97
|
+
description: 'Best for small, targeted, single-location changes within a line or paragraph. ' +
|
|
98
|
+
'Can insert text at a position, replace a range or found text, delete text (replace with empty string ""), ' +
|
|
99
|
+
'apply text styling (bold, italic, etc.), apply paragraph styling (alignment, headings, spacing, etc.), or any combination. ' +
|
|
100
|
+
"Use readDocument with format='json' to determine indices. " +
|
|
101
|
+
'Supports \\n for line breaks and \\t for tabs in replacement text. ' +
|
|
102
|
+
'When using textToFind, if multiple matches exist the tool returns all instances with context so you can specify matchInstance. ' +
|
|
103
|
+
'For multi-line or section-level rewrites, use replaceDocumentWithMarkdown instead. ' +
|
|
104
|
+
'To add content to the end of a doc, use appendMarkdown or appendText.',
|
|
99
105
|
parameters: ModifyTextParameters,
|
|
100
106
|
execute: async (args, { log }) => {
|
|
107
|
+
await guardMutation(args.documentId);
|
|
101
108
|
const docs = await getDocsClient();
|
|
102
109
|
log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
|
|
103
110
|
`${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
|
|
@@ -130,7 +137,7 @@ export function register(server) {
|
|
|
130
137
|
else if ('textToFind' in args.target) {
|
|
131
138
|
const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
|
|
132
139
|
if (!range) {
|
|
133
|
-
throw new UserError(`Could not find
|
|
140
|
+
throw new UserError(`Could not find text "${args.target.textToFind}"${args.target.matchInstance ? ` (instance ${args.target.matchInstance})` : ''}${args.tabId ? ` in tab ${args.tabId}` : ''}.`);
|
|
134
141
|
}
|
|
135
142
|
startIndex = range.startIndex;
|
|
136
143
|
endIndex = range.endIndex;
|
|
@@ -143,10 +150,15 @@ export function register(server) {
|
|
|
143
150
|
// Clamp to minimum 1 (index 0 is the document section break)
|
|
144
151
|
if (startIndex < 1)
|
|
145
152
|
startIndex = 1;
|
|
153
|
+
// Normalize escape sequences so literal \n / \t in the input
|
|
154
|
+
// are converted to real newline / tab characters (issue #9).
|
|
155
|
+
const normalizedText = args.text
|
|
156
|
+
?.replace(/\\n/g, '\n')
|
|
157
|
+
.replace(/\\t/g, '\t');
|
|
146
158
|
const requests = buildModifyTextRequests({
|
|
147
159
|
startIndex,
|
|
148
160
|
endIndex,
|
|
149
|
-
text:
|
|
161
|
+
text: normalizedText,
|
|
150
162
|
style: args.style,
|
|
151
163
|
paragraphStyle: args.paragraphStyle,
|
|
152
164
|
tabId: args.tabId,
|
|
@@ -155,9 +167,12 @@ export function register(server) {
|
|
|
155
167
|
return 'No operations to perform.';
|
|
156
168
|
}
|
|
157
169
|
await GDocsHelpers.executeBatchUpdate(docs, args.documentId, requests);
|
|
170
|
+
trackMutation(args.documentId);
|
|
158
171
|
// Build descriptive result
|
|
159
172
|
const actions = [];
|
|
160
|
-
if (endIndex !== undefined &&
|
|
173
|
+
if (endIndex !== undefined && normalizedText === '')
|
|
174
|
+
actions.push('deleted text');
|
|
175
|
+
else if (endIndex !== undefined && args.text !== undefined)
|
|
161
176
|
actions.push('replaced text');
|
|
162
177
|
else if (args.text !== undefined)
|
|
163
178
|
actions.push('inserted text');
|
|
@@ -165,7 +180,8 @@ export function register(server) {
|
|
|
165
180
|
actions.push('applied text formatting');
|
|
166
181
|
if (args.paragraphStyle)
|
|
167
182
|
actions.push('applied paragraph formatting');
|
|
168
|
-
|
|
183
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
184
|
+
return `${docUrl}\nSuccessfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
|
|
169
185
|
}
|
|
170
186
|
catch (error) {
|
|
171
187
|
log.error(`Error in modifyText for doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { buildModifyTextRequests } from './modifyText.js';
|
|
4
|
+
|
|
5
|
+
describe('buildModifyTextRequests', () => {
|
|
6
|
+
it('generates deleteContentRange + insertText for a replacement', () => {
|
|
7
|
+
const reqs = buildModifyTextRequests({
|
|
8
|
+
startIndex: 5,
|
|
9
|
+
endIndex: 10,
|
|
10
|
+
text: 'hello',
|
|
11
|
+
});
|
|
12
|
+
assert.equal(reqs.length, 2);
|
|
13
|
+
assert.deepEqual(reqs[0], {
|
|
14
|
+
deleteContentRange: { range: { startIndex: 5, endIndex: 10 } },
|
|
15
|
+
});
|
|
16
|
+
assert.deepEqual(reqs[1], {
|
|
17
|
+
insertText: { location: { index: 5 }, text: 'hello' },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('generates only insertText for an insertion (no endIndex)', () => {
|
|
22
|
+
const reqs = buildModifyTextRequests({
|
|
23
|
+
startIndex: 3,
|
|
24
|
+
endIndex: undefined,
|
|
25
|
+
text: 'hi',
|
|
26
|
+
});
|
|
27
|
+
assert.equal(reqs.length, 1);
|
|
28
|
+
assert.deepEqual(reqs[0], {
|
|
29
|
+
insertText: { location: { index: 3 }, text: 'hi' },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty array when no text/style/paragraphStyle given', () => {
|
|
34
|
+
const reqs = buildModifyTextRequests({
|
|
35
|
+
startIndex: 1,
|
|
36
|
+
endIndex: 5,
|
|
37
|
+
});
|
|
38
|
+
assert.equal(reqs.length, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('includes tabId in range and location when provided', () => {
|
|
42
|
+
const reqs = buildModifyTextRequests({
|
|
43
|
+
startIndex: 1,
|
|
44
|
+
endIndex: 4,
|
|
45
|
+
text: 'x',
|
|
46
|
+
tabId: 'tab1',
|
|
47
|
+
});
|
|
48
|
+
assert.deepEqual(reqs[0].deleteContentRange.range.tabId, 'tab1');
|
|
49
|
+
assert.deepEqual(reqs[1].insertText.location.tabId, 'tab1');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('escape sequence normalization (issue #9)', () => {
|
|
54
|
+
// Simulate the normalization that happens in the execute handler
|
|
55
|
+
function normalize(text) {
|
|
56
|
+
return text?.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('converts literal \\n to real newline', () => {
|
|
60
|
+
const input = 'line one\\nline two\\nline three';
|
|
61
|
+
const result = normalize(input);
|
|
62
|
+
assert.equal(result, 'line one\nline two\nline three');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('converts literal \\t to real tab', () => {
|
|
66
|
+
const input = 'col1\\tcol2\\tcol3';
|
|
67
|
+
const result = normalize(input);
|
|
68
|
+
assert.equal(result, 'col1\tcol2\tcol3');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('handles mixed \\n and \\t', () => {
|
|
72
|
+
const input = 'row1\\tcol2\\nrow2\\tcol2';
|
|
73
|
+
const result = normalize(input);
|
|
74
|
+
assert.equal(result, 'row1\tcol2\nrow2\tcol2');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('leaves text without escape sequences unchanged', () => {
|
|
78
|
+
const input = 'no escapes here';
|
|
79
|
+
assert.equal(normalize(input), 'no escapes here');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('preserves real newlines that are already in the string', () => {
|
|
83
|
+
const input = 'already\nreal';
|
|
84
|
+
assert.equal(normalize(input), 'already\nreal');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns undefined for undefined input', () => {
|
|
88
|
+
assert.equal(normalize(undefined), undefined);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -4,6 +4,7 @@ import { getDocsClient, getDriveClient } from '../../clients.js';
|
|
|
4
4
|
import { DocumentIdParameter, NotImplementedError } from '../../types.js';
|
|
5
5
|
import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
|
|
6
6
|
import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
|
|
7
|
+
import { trackRead } from '../../readTracker.js';
|
|
7
8
|
export function register(server) {
|
|
8
9
|
server.addTool({
|
|
9
10
|
name: 'readDocument',
|
|
@@ -38,6 +39,7 @@ export function register(server) {
|
|
|
38
39
|
fields: needsTabsContent ? '*' : fields, // Get full document if using tabs
|
|
39
40
|
});
|
|
40
41
|
log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
|
|
42
|
+
trackRead(args.documentId);
|
|
41
43
|
// If tabId is specified, find the specific tab
|
|
42
44
|
let contentSource;
|
|
43
45
|
if (args.tabId) {
|
|
@@ -44,7 +44,8 @@ export function register(server) {
|
|
|
44
44
|
],
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
|
-
|
|
47
|
+
const docUrl = `https://docs.google.com/document/d/${args.documentId}/edit`;
|
|
48
|
+
return `${docUrl}\nSuccessfully renamed tab from "${oldTitle}" to "${args.newTitle}".`;
|
|
48
49
|
}
|
|
49
50
|
catch (error) {
|
|
50
51
|
log.error(`Error renaming tab ${args.tabId} in doc ${args.documentId}: ${error.message || error}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UserError } from 'fastmcp';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getDriveClient } from '../../clients.js';
|
|
4
|
+
import { guardMutation } from '../../readTracker.js';
|
|
4
5
|
export function register(server) {
|
|
5
6
|
server.addTool({
|
|
6
7
|
name: 'deleteFile',
|
|
@@ -16,6 +17,7 @@ export function register(server) {
|
|
|
16
17
|
.describe('If true, permanently deletes the file instead of moving it to trash.'),
|
|
17
18
|
}),
|
|
18
19
|
execute: async (args, { log }) => {
|
|
20
|
+
await guardMutation(args.fileId, { skipExternalCheck: true });
|
|
19
21
|
const drive = await getDriveClient();
|
|
20
22
|
log.info(`Deleting file ${args.fileId} ${args.permanent ? '(permanent)' : '(to trash)'}`);
|
|
21
23
|
try {
|
|
@@ -14,6 +14,7 @@ import { register as listSharedDrives } from './listSharedDrives.js';
|
|
|
14
14
|
import { register as listSharedWithMe } from './listSharedWithMe.js';
|
|
15
15
|
import { register as downloadFile } from './downloadFile.js';
|
|
16
16
|
import { register as getFilePath } from './getFilePath.js';
|
|
17
|
+
import { register as uploadFile } from './uploadFile.js';
|
|
17
18
|
export function registerDriveTools(server) {
|
|
18
19
|
listDriveFiles(server);
|
|
19
20
|
searchGoogleDocs(server);
|
|
@@ -31,4 +32,5 @@ export function registerDriveTools(server) {
|
|
|
31
32
|
listSharedWithMe(server);
|
|
32
33
|
downloadFile(server);
|
|
33
34
|
getFilePath(server);
|
|
35
|
+
uploadFile(server);
|
|
34
36
|
}
|
|
@@ -31,7 +31,7 @@ export function register(server) {
|
|
|
31
31
|
let updateParams = {
|
|
32
32
|
fileId: args.fileId,
|
|
33
33
|
addParents: args.newParentId,
|
|
34
|
-
fields: 'id,name,parents',
|
|
34
|
+
fields: 'id,name,parents,webViewLink',
|
|
35
35
|
supportsAllDrives: true,
|
|
36
36
|
};
|
|
37
37
|
if (args.removeFromAllParents && currentParents.length > 0) {
|
|
@@ -39,7 +39,8 @@ export function register(server) {
|
|
|
39
39
|
}
|
|
40
40
|
const response = await drive.files.update(updateParams);
|
|
41
41
|
const action = args.removeFromAllParents ? 'moved' : 'copied';
|
|
42
|
-
|
|
42
|
+
const fileUrl = response.data.webViewLink || `https://drive.google.com/file/d/${response.data.id}/view`;
|
|
43
|
+
return `${fileUrl}\nSuccessfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`;
|
|
43
44
|
}
|
|
44
45
|
catch (error) {
|
|
45
46
|
log.error(`Error moving file: ${error.message || error}`);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { UserError } from 'fastmcp';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { getDriveClient } from '../../clients.js';
|
|
6
|
+
|
|
7
|
+
// Common MIME type mappings for file extensions
|
|
8
|
+
const MIME_TYPES = {
|
|
9
|
+
'.pdf': 'application/pdf',
|
|
10
|
+
'.doc': 'application/msword',
|
|
11
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
12
|
+
'.xls': 'application/vnd.ms-excel',
|
|
13
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
14
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
15
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
16
|
+
'.txt': 'text/plain',
|
|
17
|
+
'.csv': 'text/csv',
|
|
18
|
+
'.json': 'application/json',
|
|
19
|
+
'.xml': 'application/xml',
|
|
20
|
+
'.html': 'text/html',
|
|
21
|
+
'.htm': 'text/html',
|
|
22
|
+
'.md': 'text/markdown',
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.jpeg': 'image/jpeg',
|
|
26
|
+
'.gif': 'image/gif',
|
|
27
|
+
'.svg': 'image/svg+xml',
|
|
28
|
+
'.webp': 'image/webp',
|
|
29
|
+
'.mp3': 'audio/mpeg',
|
|
30
|
+
'.mp4': 'video/mp4',
|
|
31
|
+
'.zip': 'application/zip',
|
|
32
|
+
'.gz': 'application/gzip',
|
|
33
|
+
'.tar': 'application/x-tar',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function register(server) {
|
|
37
|
+
server.addTool({
|
|
38
|
+
name: 'uploadFile',
|
|
39
|
+
description:
|
|
40
|
+
'Uploads a local file from the filesystem to Google Drive. ' +
|
|
41
|
+
'Auto-detects MIME type from file extension. ' +
|
|
42
|
+
'Returns the uploaded file\'s Drive ID and URL.',
|
|
43
|
+
parameters: z.object({
|
|
44
|
+
localPath: z.string().describe('Absolute path to the local file to upload.'),
|
|
45
|
+
name: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe('Custom name for the file in Drive. If not provided, uses the local filename.'),
|
|
49
|
+
parentFolderId: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe('ID of the Drive folder to upload into. If not provided, uploads to the root of My Drive.'),
|
|
53
|
+
mimeType: z
|
|
54
|
+
.string()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe('MIME type override. If not provided, auto-detected from file extension.'),
|
|
57
|
+
}),
|
|
58
|
+
execute: async (args, { log }) => {
|
|
59
|
+
const drive = await getDriveClient();
|
|
60
|
+
|
|
61
|
+
// Validate file exists
|
|
62
|
+
if (!fs.existsSync(args.localPath)) {
|
|
63
|
+
throw new UserError(`File not found: ${args.localPath}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stats = fs.statSync(args.localPath);
|
|
67
|
+
if (!stats.isFile()) {
|
|
68
|
+
throw new UserError(`Path is not a file: ${args.localPath}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fileName = args.name || path.basename(args.localPath);
|
|
72
|
+
const ext = path.extname(args.localPath).toLowerCase();
|
|
73
|
+
const mimeType = args.mimeType || MIME_TYPES[ext] || 'application/octet-stream';
|
|
74
|
+
|
|
75
|
+
log.info(`Uploading "${args.localPath}" as "${fileName}" (${mimeType}, ${stats.size} bytes)`);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const fileMetadata = { name: fileName };
|
|
79
|
+
if (args.parentFolderId) {
|
|
80
|
+
fileMetadata.parents = [args.parentFolderId];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const response = await drive.files.create({
|
|
84
|
+
requestBody: fileMetadata,
|
|
85
|
+
media: {
|
|
86
|
+
mimeType,
|
|
87
|
+
body: fs.createReadStream(args.localPath),
|
|
88
|
+
},
|
|
89
|
+
fields: 'id,name,webViewLink,mimeType,size',
|
|
90
|
+
supportsAllDrives: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const file = response.data;
|
|
94
|
+
return JSON.stringify({
|
|
95
|
+
id: file.id,
|
|
96
|
+
name: file.name,
|
|
97
|
+
url: file.webViewLink,
|
|
98
|
+
mimeType: file.mimeType,
|
|
99
|
+
size: file.size,
|
|
100
|
+
}, null, 2);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
log.error(`Error uploading file: ${error.message || error}`);
|
|
103
|
+
if (error.code === 404)
|
|
104
|
+
throw new UserError('Destination folder not found. Check the parentFolderId.');
|
|
105
|
+
if (error.code === 403)
|
|
106
|
+
throw new UserError('Permission denied. Make sure you have write access to the destination folder.');
|
|
107
|
+
throw new UserError(`Failed to upload file: ${error.message || 'Unknown error'}`);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { register as readFile } from './readFile.js';
|
|
2
2
|
import { register as searchFileContents } from './searchFileContents.js';
|
|
3
|
+
import { register as readDriveFile } from './readDriveFile.js';
|
|
3
4
|
|
|
4
5
|
export function registerExtrasTools(server) {
|
|
5
6
|
readFile(server);
|
|
6
7
|
searchFileContents(server);
|
|
8
|
+
readDriveFile(server);
|
|
7
9
|
}
|