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.
Files changed (60) hide show
  1. package/dist/googleDocsApiHelpers.js +220 -108
  2. package/dist/markdown-transformer/index.js +23 -3
  3. package/dist/markdown-transformer/markdownToDocs.js +25 -0
  4. package/dist/readTracker.js +84 -0
  5. package/dist/tools/docs/addTab.js +1 -0
  6. package/dist/tools/docs/appendToGoogleDoc.js +24 -4
  7. package/dist/tools/docs/comments/addComment.js +2 -1
  8. package/dist/tools/docs/comments/deleteComment.js +2 -1
  9. package/dist/tools/docs/comments/replyToComment.js +2 -1
  10. package/dist/tools/docs/comments/resolveComment.js +3 -2
  11. package/dist/tools/docs/deleteRange.js +5 -1
  12. package/dist/tools/docs/findAndReplace.js +5 -1
  13. package/dist/tools/docs/formatting/applyParagraphStyle.js +4 -3
  14. package/dist/tools/docs/insertImage.js +4 -2
  15. package/dist/tools/docs/insertPageBreak.js +2 -1
  16. package/dist/tools/docs/insertTable.js +2 -1
  17. package/dist/tools/docs/insertTableWithData.js +2 -1
  18. package/dist/tools/docs/modifyText.js +27 -11
  19. package/dist/tools/docs/modifyText.test.js +90 -0
  20. package/dist/tools/docs/readGoogleDoc.js +2 -0
  21. package/dist/tools/docs/renameTab.js +2 -1
  22. package/dist/tools/drive/deleteFile.js +2 -0
  23. package/dist/tools/drive/index.js +2 -0
  24. package/dist/tools/drive/moveFile.js +3 -2
  25. package/dist/tools/drive/uploadFile.js +111 -0
  26. package/dist/tools/extras/index.js +2 -0
  27. package/dist/tools/extras/readDriveFile.js +206 -0
  28. package/dist/tools/extras/readFile.js +2 -0
  29. package/dist/tools/forms/batchUpdateForm.js +1 -0
  30. package/dist/tools/forms/setPublishSettings.js +1 -0
  31. package/dist/tools/index.js +117 -11
  32. package/dist/tools/sheets/addConditionalFormatting.js +2 -1
  33. package/dist/tools/sheets/addSpreadsheetSheet.js +2 -1
  34. package/dist/tools/sheets/appendSpreadsheetRows.js +2 -1
  35. package/dist/tools/sheets/appendTableRows.js +1 -0
  36. package/dist/tools/sheets/autoResizeColumns.js +2 -1
  37. package/dist/tools/sheets/batchWrite.js +4 -1
  38. package/dist/tools/sheets/clearSpreadsheetRange.js +4 -1
  39. package/dist/tools/sheets/copyFormatting.js +2 -1
  40. package/dist/tools/sheets/createTable.js +1 -0
  41. package/dist/tools/sheets/deleteChart.js +2 -1
  42. package/dist/tools/sheets/deleteSheet.js +2 -1
  43. package/dist/tools/sheets/deleteTable.js +1 -0
  44. package/dist/tools/sheets/duplicateSheet.js +2 -1
  45. package/dist/tools/sheets/formatCells.js +2 -1
  46. package/dist/tools/sheets/freezeRowsAndColumns.js +2 -1
  47. package/dist/tools/sheets/getSpreadsheetInfo.js +2 -0
  48. package/dist/tools/sheets/groupRows.js +2 -1
  49. package/dist/tools/sheets/insertChart.js +2 -1
  50. package/dist/tools/sheets/readSpreadsheet.js +2 -0
  51. package/dist/tools/sheets/renameSheet.js +2 -1
  52. package/dist/tools/sheets/setColumnWidths.js +2 -1
  53. package/dist/tools/sheets/setDropdownValidation.js +3 -2
  54. package/dist/tools/sheets/ungroupAllRows.js +2 -1
  55. package/dist/tools/sheets/updateTableRange.js +1 -0
  56. package/dist/tools/sheets/writeSpreadsheet.js +4 -1
  57. package/dist/tools/utils/appendMarkdownToGoogleDoc.js +27 -5
  58. package/dist/tools/utils/replaceDocumentWithMarkdown.js +28 -5
  59. package/dist/types.js +1 -2
  60. 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
- return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Replaced ${changed} occurrence(s) of "${args.findText}" with "${args.replaceText}".`;
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 || 1})${args.tabId ? ` in tab ${args.tabId}` : ''}`);
22
- const textRange = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance || 1, args.tabId);
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
- return `Successfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Successfully inserted local image at index ${args.index} via Apps Script${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Successfully inserted image at index ${args.index}${sizeInfo}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Successfully inserted page break at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Successfully inserted a ${args.rows}x${args.columns} table at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return (`Successfully inserted a ${numRows}x${numCols} table with data at index ${args.index}` +
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 (!text && !style && !paragraphStyle)
46
+ if (text === undefined && !style && !paragraphStyle)
46
47
  return requests;
47
- // 1. Delete existing content (only when replacing, not insert-only)
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: 'Combines text replacement/insertion and formatting in one atomic operation. ' +
97
- 'Can insert text at a position, replace a range or found text, apply text styling (bold, italic, etc.), ' +
98
- "apply paragraph styling (alignment, headings, spacing, etc.), or any combination. Use readGoogleDoc with format='json' to determine indices.",
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 instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`);
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: args.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 && args.text !== 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
- return `Successfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
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
- return `Successfully renamed tab from "${oldTitle}" to "${args.newTitle}".`;
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
- return `Successfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`;
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
  }