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
@@ -0,0 +1,206 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient, getDocsClient, getSheetsClient } from '../../clients.js';
4
+ import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
5
+ import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
+ import { extractText, downloadBuffer } from './readFile.js';
7
+ import { trackRead } from '../../readTracker.js';
8
+
9
+ // Google-native MIME types
10
+ const GOOGLE_DOC = 'application/vnd.google-apps.document';
11
+ const GOOGLE_SHEET = 'application/vnd.google-apps.spreadsheet';
12
+ const GOOGLE_SLIDES = 'application/vnd.google-apps.presentation';
13
+
14
+ // Capabilities by file type category
15
+ const CAPABILITIES = {
16
+ googleDoc: {
17
+ fileType: 'Google Doc',
18
+ capabilities: ['read', 'readDocument', 'modifyText', 'replaceDocumentWithMarkdown', 'appendMarkdown', 'appendText', 'findAndReplace', 'deleteRange'],
19
+ },
20
+ googleSheet: {
21
+ fileType: 'Google Sheet',
22
+ capabilities: ['read', 'readSpreadsheet', 'writeSpreadsheet', 'appendRows', 'formatCells'],
23
+ },
24
+ docx: {
25
+ fileType: 'Word Document (.docx)',
26
+ capabilities: ['read'],
27
+ note: 'This is a Word document. Editing tools (modifyText, appendMarkdown, etc.) are not available unless converted to a Google Doc via copyFile or Drive UI.',
28
+ },
29
+ pdf: {
30
+ fileType: 'PDF',
31
+ capabilities: ['read'],
32
+ note: 'This is a PDF file. Editing tools are not available. Convert to a Google Doc first if editing is needed.',
33
+ },
34
+ other: {
35
+ fileType: 'Unknown',
36
+ capabilities: ['download'],
37
+ note: 'This file type cannot be read as text. Use downloadFile to download it.',
38
+ },
39
+ };
40
+
41
+ export function register(server) {
42
+ server.addTool({
43
+ name: 'readDriveFile',
44
+ description:
45
+ 'Unified read tool: accepts any Google Drive file ID, auto-detects the file type (Google Doc, Sheet, Word .docx, PDF), ' +
46
+ 'and routes to the appropriate reader. Returns content along with metadata about available capabilities. ' +
47
+ 'Use this when you have a file ID but don\'t know the file type, or when readDocument fails with a 400 error.',
48
+ parameters: z.object({
49
+ fileId: z
50
+ .string()
51
+ .describe('The Google Drive file ID (the long string between /d/ and /edit in a Drive URL).'),
52
+ format: z
53
+ .enum(['text', 'json', 'markdown'])
54
+ .optional()
55
+ .default('markdown')
56
+ .describe("Output format for Google Docs: 'markdown' (default), 'text' (plain text), 'json' (raw structure). Ignored for non-Doc files."),
57
+ range: z
58
+ .string()
59
+ .optional()
60
+ .describe("A1 notation range for Google Sheets (e.g., 'Sheet1!A1:C10'). Required for spreadsheets."),
61
+ }),
62
+ execute: async (args, { log }) => {
63
+ const drive = await getDriveClient();
64
+
65
+ // 1. Get file metadata to determine type
66
+ let fileMeta;
67
+ try {
68
+ const res = await drive.files.get({
69
+ fileId: args.fileId,
70
+ fields: 'id,name,mimeType,modifiedTime,size',
71
+ supportsAllDrives: true,
72
+ });
73
+ fileMeta = res.data;
74
+ } catch (error) {
75
+ if (error.code === 404) throw new UserError(`File not found: ${args.fileId}`);
76
+ if (error.code === 403) throw new UserError('Permission denied. Check that the file is shared with this account.');
77
+ throw new UserError(`Failed to get file info: ${error.message}`);
78
+ }
79
+
80
+ log.info(`File "${fileMeta.name}" is ${fileMeta.mimeType}`);
81
+ trackRead(args.fileId, fileMeta.modifiedTime);
82
+
83
+ // 2. Route based on MIME type
84
+ if (fileMeta.mimeType === GOOGLE_DOC) {
85
+ return await readGoogleDoc(args, fileMeta, log);
86
+ }
87
+
88
+ if (fileMeta.mimeType === GOOGLE_SHEET) {
89
+ return await readGoogleSheet(args, fileMeta, log);
90
+ }
91
+
92
+ // Try reading as docx/pdf
93
+ const text = await tryReadBinaryFile(drive, args.fileId, fileMeta, log);
94
+ if (text !== null) {
95
+ return text;
96
+ }
97
+
98
+ // Unsupported type
99
+ const capInfo = CAPABILITIES.other;
100
+ capInfo.fileType = fileMeta.mimeType;
101
+ return JSON.stringify({
102
+ file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
103
+ ...capInfo,
104
+ content: null,
105
+ }, null, 2);
106
+ },
107
+ });
108
+ }
109
+
110
+ async function readGoogleDoc(args, fileMeta, log) {
111
+ const docs = await getDocsClient();
112
+ const res = await docs.documents.get({
113
+ documentId: args.fileId,
114
+ fields: args.format === 'text' ? 'body(content(paragraph(elements(textRun(content)))))' : '*',
115
+ });
116
+
117
+ let content;
118
+ if (args.format === 'json') {
119
+ content = JSON.stringify(res.data, null, 2);
120
+ } else if (args.format === 'markdown') {
121
+ content = docsJsonToMarkdown(res.data);
122
+ } else {
123
+ // text
124
+ let textContent = '';
125
+ res.data.body?.content?.forEach((element) => {
126
+ if (element.paragraph?.elements) {
127
+ element.paragraph.elements.forEach((pe) => {
128
+ if (pe.textRun?.content) textContent += pe.textRun.content;
129
+ });
130
+ }
131
+ if (element.table?.tableRows) {
132
+ element.table.tableRows.forEach((row) => {
133
+ row.tableCells?.forEach((cell) => {
134
+ cell.content?.forEach((cellElement) => {
135
+ cellElement.paragraph?.elements?.forEach((pe) => {
136
+ if (pe.textRun?.content) textContent += pe.textRun.content;
137
+ });
138
+ });
139
+ });
140
+ });
141
+ }
142
+ });
143
+ content = textContent || '(empty document)';
144
+ }
145
+
146
+ return JSON.stringify({
147
+ file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
148
+ ...CAPABILITIES.googleDoc,
149
+ content,
150
+ }, null, 2);
151
+ }
152
+
153
+ async function readGoogleSheet(args, fileMeta, log) {
154
+ if (!args.range) {
155
+ // Return sheet info without data
156
+ const sheets = await getSheetsClient();
157
+ const info = await sheets.spreadsheets.get({
158
+ spreadsheetId: args.fileId,
159
+ fields: 'sheets(properties(sheetId,title,gridProperties))',
160
+ });
161
+ const sheetList = info.data.sheets?.map(s => ({
162
+ title: s.properties.title,
163
+ sheetId: s.properties.sheetId,
164
+ rows: s.properties.gridProperties?.rowCount,
165
+ cols: s.properties.gridProperties?.columnCount,
166
+ }));
167
+ return JSON.stringify({
168
+ file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
169
+ ...CAPABILITIES.googleSheet,
170
+ note: 'Provide a range parameter (e.g., "Sheet1!A1:C10") to read data. Available sheets listed below.',
171
+ sheets: sheetList,
172
+ }, null, 2);
173
+ }
174
+
175
+ const sheets = await getSheetsClient();
176
+ const response = await sheets.spreadsheets.values.get({
177
+ spreadsheetId: args.fileId,
178
+ range: args.range,
179
+ });
180
+ return JSON.stringify({
181
+ file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
182
+ ...CAPABILITIES.googleSheet,
183
+ range: args.range,
184
+ values: response.data.values || [],
185
+ }, null, 2);
186
+ }
187
+
188
+ async function tryReadBinaryFile(drive, fileId, fileMeta, log) {
189
+ const { mimeType, name } = fileMeta;
190
+ const isDocxFile = mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || name?.endsWith('.docx');
191
+ const isPdfFile = mimeType === 'application/pdf' || name?.endsWith('.pdf');
192
+
193
+ if (!isDocxFile && !isPdfFile) return null;
194
+
195
+ const buffer = await downloadBuffer(drive, fileId);
196
+ const text = await extractText(buffer, mimeType, name);
197
+
198
+ if (text === null) return null;
199
+
200
+ const capInfo = isDocxFile ? CAPABILITIES.docx : CAPABILITIES.pdf;
201
+ return JSON.stringify({
202
+ file: { id: fileMeta.id, name: fileMeta.name, mimeType: fileMeta.mimeType },
203
+ ...capInfo,
204
+ content: text,
205
+ }, null, 2);
206
+ }
@@ -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 { trackRead } from '../../readTracker.js';
4
5
  import mammoth from 'mammoth';
5
6
 
6
7
  function isDocx(mimeType, name) {
@@ -58,6 +59,7 @@ export function register(server) {
58
59
  });
59
60
 
60
61
  const { name, mimeType } = meta.data;
62
+ trackRead(fileId);
61
63
  const buffer = await downloadBuffer(drive, fileId);
62
64
  const text = await extractText(buffer, mimeType, name);
63
65
 
@@ -49,6 +49,7 @@ export function register(server) {
49
49
  const replies = data.replies || [];
50
50
 
51
51
  const result = {
52
+ url: `https://docs.google.com/forms/d/${args.formId}/edit`,
52
53
  requestsApplied: args.requests.length,
53
54
  repliesReceived: replies.length,
54
55
  };
@@ -41,6 +41,7 @@ export function register(server) {
41
41
  });
42
42
 
43
43
  return JSON.stringify({
44
+ url: `https://docs.google.com/forms/d/${args.formId}/edit`,
44
45
  success: true,
45
46
  formId: args.formId,
46
47
  message: 'Publish settings updated successfully.',
@@ -6,21 +6,103 @@ import * as fs from 'fs/promises';
6
6
  import * as path from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import * as os from 'os';
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
9
11
  import { getTokenPath, getConfigDir, SCOPES } from '../auth.js';
10
12
  import { resetClients, withAuthRetry, getAuthClientIfReady } from '../clients.js';
11
13
  import { logger } from '../logger.js';
12
14
  import { google } from 'googleapis';
13
15
 
16
+ const execAsync = promisify(exec);
17
+
18
+ const REPO = 'karthikcsq/google-tools-mcp';
19
+
20
+ async function tryGhCli(title, body, label) {
21
+ // Probe for gh CLI
22
+ try {
23
+ await execAsync('gh --version');
24
+ } catch {
25
+ return { ok: false, reason: 'gh CLI not installed' };
26
+ }
27
+ // Probe for auth
28
+ try {
29
+ await execAsync('gh auth status');
30
+ } catch {
31
+ return { ok: false, reason: 'gh CLI not authenticated (run: gh auth login)' };
32
+ }
33
+ // Write body to a temp file to avoid shell-escaping issues with newlines/quotes.
34
+ const tmpFile = path.join(os.tmpdir(), `gtm-feedback-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
35
+ try {
36
+ await fs.writeFile(tmpFile, body, 'utf8');
37
+ const { stdout } = await execAsync(
38
+ `gh issue create --repo ${REPO} --title ${JSON.stringify(title)} --label ${JSON.stringify(label)} --body-file ${JSON.stringify(tmpFile)}`,
39
+ { maxBuffer: 10 * 1024 * 1024 }
40
+ );
41
+ const issueUrl = stdout.trim().split('\n').pop();
42
+ return { ok: true, issueUrl };
43
+ } catch (err) {
44
+ return { ok: false, reason: `gh CLI failed: ${err.stderr || err.message || err}` };
45
+ } finally {
46
+ try { await fs.unlink(tmpFile); } catch {}
47
+ }
48
+ }
49
+
50
+ function openBrowser(url) {
51
+ const platform = process.platform;
52
+ let cmd;
53
+ if (platform === 'win32') {
54
+ cmd = `start "" "${url}"`;
55
+ } else if (platform === 'darwin') {
56
+ cmd = `open "${url}"`;
57
+ } else {
58
+ cmd = `xdg-open "${url}"`;
59
+ }
60
+ return new Promise((resolve) => {
61
+ exec(cmd, (err) => resolve(!err));
62
+ });
63
+ }
64
+
14
65
  // ---------------------------------------------------------------------------
15
- // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant.
66
+ // Wrap server.addTool so every tool's execute() auto-retries on invalid_grant
67
+ // and appends a troubleshoot/feedback hint to errors.
16
68
  // ---------------------------------------------------------------------------
69
+ const ERROR_HINT =
70
+ '\n\nIf this error is unexpected or unclear, you can:\n' +
71
+ ' • Call the `troubleshoot` tool to run a health check (auth, API connectivity, recent logs).\n' +
72
+ ' • Call the `feedback` tool to file a bug report with diagnostics auto-attached.';
73
+
74
+ // Tools that should NOT have the hint appended (would be circular/noisy).
75
+ const HINT_EXCLUDED_TOOLS = new Set(['troubleshoot', 'feedback', 'help', 'logout']);
76
+
77
+ function appendHintToError(error, toolName) {
78
+ if (HINT_EXCLUDED_TOOLS.has(toolName)) return error;
79
+ if (!error) return error;
80
+ // Avoid double-appending if something else (or a retry) already added it.
81
+ const existingMsg = error.message || '';
82
+ if (existingMsg.includes('`troubleshoot` tool')) return error;
83
+ try {
84
+ error.message = existingMsg + ERROR_HINT;
85
+ } catch {
86
+ // Some error types have non-writable message; fall back to a new error.
87
+ const wrapped = new Error(existingMsg + ERROR_HINT);
88
+ wrapped.cause = error;
89
+ return wrapped;
90
+ }
91
+ return error;
92
+ }
93
+
17
94
  function wrapServerWithAuthRetry(server) {
18
95
  const originalAddTool = server.addTool.bind(server);
19
96
  server.addTool = function (toolDef) {
20
97
  const originalExecute = toolDef.execute;
98
+ const toolName = toolDef.name;
21
99
  if (originalExecute) {
22
- toolDef.execute = function (...args) {
23
- return withAuthRetry(() => originalExecute.apply(this, args));
100
+ toolDef.execute = async function (...args) {
101
+ try {
102
+ return await withAuthRetry(() => originalExecute.apply(this, args));
103
+ } catch (err) {
104
+ throw appendHintToError(err, toolName);
105
+ }
24
106
  };
25
107
  }
26
108
  return originalAddTool(toolDef);
@@ -109,15 +191,22 @@ export async function registerAllTools(server) {
109
191
  server.addTool({
110
192
  name: 'help',
111
193
  description:
112
- 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. Call this when you need guidance on how to use the Google Workspace tools.',
194
+ 'Show documentation for google-tools-mcp: setup instructions, available tool categories, environment variables, and troubleshooting. ' +
195
+ 'Call this when you need guidance on how to use the Google Workspace tools. ' +
196
+ 'Also available: `troubleshoot` (run health check when tools fail) and `feedback` (submit bug reports/feature requests with diagnostics auto-attached).',
113
197
  parameters: z.object({}),
114
198
  execute: async () => {
115
199
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
116
200
  const readmePath = path.resolve(__dirname, '..', '..', 'README.md');
201
+ const diagnosticsSection = '\n\n## Diagnostics & Feedback\n\n' +
202
+ '- **`troubleshoot`** — Run a health check when tools fail (checks auth, API connectivity, config, recent logs).\n' +
203
+ '- **`feedback`** — Submit a bug report or feature request with diagnostics auto-attached (files via GitHub CLI or browser).\n' +
204
+ '- **`help`** — Show this documentation.\n';
117
205
  try {
118
- return await fs.readFile(readmePath, 'utf-8');
206
+ const readme = await fs.readFile(readmePath, 'utf-8');
207
+ return readme + diagnosticsSection;
119
208
  } catch {
120
- return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.';
209
+ return 'README not found. Visit https://www.npmjs.com/package/google-tools-mcp for documentation.' + diagnosticsSection;
121
210
  }
122
211
  },
123
212
  });
@@ -275,7 +364,7 @@ export async function registerAllTools(server) {
275
364
  server.addTool({
276
365
  name: 'feedback',
277
366
  description:
278
- 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info and generates a pre-filled GitHub issue URL.',
367
+ 'Submit feedback or a bug report for google-tools-mcp. Automatically collects diagnostic info, then files the issue via the GitHub CLI (`gh`) if available, or falls back to opening a pre-filled GitHub issue URL in the user\'s browser.',
279
368
  parameters: z.object({
280
369
  type: z.enum(['bug', 'feature']).describe('Type of feedback'),
281
370
  title: z.string().describe('Short summary'),
@@ -334,21 +423,38 @@ export async function registerAllTools(server) {
334
423
  `</details>`,
335
424
  ].join('\n');
336
425
 
337
- // Build GitHub issue URL
338
426
  const label = args.type === 'bug' ? 'bug' : 'enhancement';
427
+
428
+ // Try gh CLI first
429
+ const ghResult = await tryGhCli(args.title, body, label);
430
+ if (ghResult.ok) {
431
+ return JSON.stringify({
432
+ method: 'gh-cli',
433
+ issueUrl: ghResult.issueUrl,
434
+ note: 'Issue filed successfully via GitHub CLI.',
435
+ }, null, 2);
436
+ }
437
+
438
+ // Fallback: open pre-filled GitHub issue URL in the user's browser
339
439
  const params = new URLSearchParams({
340
440
  title: args.title,
341
441
  body,
342
442
  labels: label,
343
443
  });
344
- const url = `https://github.com/karthikcsq/google-tools-mcp/issues/new?${params.toString()}`;
444
+ const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
445
+ const opened = await openBrowser(url);
345
446
 
346
447
  return JSON.stringify({
448
+ method: 'browser-fallback',
449
+ ghCliUnavailableReason: ghResult.reason,
347
450
  url,
451
+ browserOpened: opened,
348
452
  markdown: body,
349
453
  note: url.length > 8000
350
- ? 'The URL may be too long for some browsers. Use the markdown body below to create the issue manually.'
351
- : 'Open the URL to create a pre-filled GitHub issue.',
454
+ ? 'The URL may be too long for some browsers. Use the markdown body to create the issue manually.'
455
+ : opened
456
+ ? 'Opened the pre-filled GitHub issue in your browser. Click "Submit new issue" to file it.'
457
+ : 'Could not auto-open browser. Please open the URL manually.',
352
458
  }, null, 2);
353
459
  },
354
460
  });
@@ -130,7 +130,8 @@ export function register(server) {
130
130
  format.textFormat = textFormat;
131
131
  }
132
132
  await SheetsHelpers.addConditionalFormatRule(sheets, args.spreadsheetId, gridRanges, args.conditionType, conditionValues, format);
133
- return `Successfully added conditional formatting rule to ${args.ranges.join(', ')}.`;
133
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
134
+ return `${sheetUrl}\nSuccessfully added conditional formatting rule to ${args.ranges.join(', ')}.`;
134
135
  }
135
136
  catch (error) {
136
137
  log.error(`Error adding conditional format rule: ${error.message || error}`);
@@ -21,7 +21,8 @@ export function register(server) {
21
21
  if (!addedSheet) {
22
22
  throw new UserError('Failed to add sheet - no sheet properties returned.');
23
23
  }
24
- return `Successfully added sheet "${addedSheet.title}" (Sheet ID: ${addedSheet.sheetId}) to spreadsheet.`;
24
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
25
+ return `${sheetUrl}\nSuccessfully added sheet "${addedSheet.title}" (Sheet ID: ${addedSheet.sheetId}) to spreadsheet.`;
25
26
  }
26
27
  catch (error) {
27
28
  log.error(`Error adding sheet to spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -30,7 +30,8 @@ export function register(server) {
30
30
  const updatedCells = response.updates?.updatedCells || 0;
31
31
  const updatedRows = response.updates?.updatedRows || 0;
32
32
  const updatedRange = response.updates?.updatedRange || args.range;
33
- return `Successfully appended ${updatedRows} row(s) (${updatedCells} cells) to spreadsheet. Updated range: ${updatedRange}`;
33
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
34
+ return `${sheetUrl}\nSuccessfully appended ${updatedRows} row(s) (${updatedCells} cells) to spreadsheet. Updated range: ${updatedRange}`;
34
35
  }
35
36
  catch (error) {
36
37
  log.error(`Error appending to spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -32,6 +32,7 @@ export function register(server) {
32
32
  // Append rows to the table
33
33
  const result = await SheetsHelpers.appendToTableHelper(sheets, args.spreadsheetId, table.tableId || '', args.values);
34
34
  return JSON.stringify({
35
+ url: `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`,
35
36
  tableId: table.tableId,
36
37
  name: table.name,
37
38
  rowsAppended: result.rowsAppended,
@@ -54,7 +54,8 @@ export function register(server) {
54
54
  },
55
55
  });
56
56
  const rangeDesc = args.columns ? `columns ${args.columns}` : 'all columns';
57
- return `Successfully auto-resized ${rangeDesc} to fit content.`;
57
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
58
+ return `${sheetUrl}\nSuccessfully auto-resized ${rangeDesc} to fit content.`;
58
59
  }
59
60
  catch (error) {
60
61
  log.error(`Error auto-resizing columns: ${error.message || error}`);
@@ -1,6 +1,7 @@
1
1
  import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
+ import { guardMutation, trackMutation } from '../../readTracker.js';
4
5
  export function register(server) {
5
6
  server.addTool({
6
7
  name: 'batchWrite',
@@ -25,6 +26,7 @@ export function register(server) {
25
26
  .describe('How input data should be interpreted. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed by a user.'),
26
27
  }),
27
28
  execute: async (args, { log }) => {
29
+ await guardMutation(args.spreadsheetId);
28
30
  const sheets = await getSheetsClient();
29
31
  const rangeNames = args.data.map((d) => d.range).join(', ');
30
32
  log.info(`Batch writing to ${args.data.length} range(s) in spreadsheet ${args.spreadsheetId}: ${rangeNames}`);
@@ -40,7 +42,8 @@ export function register(server) {
40
42
  const totalRows = response.data.totalUpdatedRows || 0;
41
43
  const totalColumns = response.data.totalUpdatedColumns || 0;
42
44
  const totalSheets = response.data.totalUpdatedSheets || 0;
43
- return `Successfully batch-wrote ${totalCells} cells (${totalRows} rows, ${totalColumns} columns) across ${totalSheets} sheet(s) in ${args.data.length} range(s).`;
45
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
46
+ return `${sheetUrl}\nSuccessfully batch-wrote ${totalCells} cells (${totalRows} rows, ${totalColumns} columns) across ${totalSheets} sheet(s) in ${args.data.length} range(s).`;
44
47
  }
45
48
  catch (error) {
46
49
  log.error(`Error batch writing to spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { guardMutation, trackMutation } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'clearRange',
@@ -13,12 +14,14 @@ export function register(server) {
13
14
  range: z.string().describe('A1 notation range to clear (e.g., "A1:B10" or "Sheet1!A1:B10").'),
14
15
  }),
15
16
  execute: async (args, { log }) => {
17
+ await guardMutation(args.spreadsheetId);
16
18
  const sheets = await getSheetsClient();
17
19
  log.info(`Clearing range ${args.range} in spreadsheet ${args.spreadsheetId}`);
18
20
  try {
19
21
  const response = await SheetsHelpers.clearRange(sheets, args.spreadsheetId, args.range);
20
22
  const clearedRange = response.clearedRange || args.range;
21
- return `Successfully cleared range ${clearedRange}.`;
23
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
24
+ return `${sheetUrl}\nSuccessfully cleared range ${clearedRange}.`;
22
25
  }
23
26
  catch (error) {
24
27
  log.error(`Error clearing range in spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -46,7 +46,8 @@ export function register(server) {
46
46
  ],
47
47
  },
48
48
  });
49
- return `Successfully copied formatting from ${args.sourceSheetName}!${args.sourceRange} to ${args.destinationSheetName}!${args.destinationRange}.`;
49
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
50
+ return `${sheetUrl}\nSuccessfully copied formatting from ${args.sourceSheetName}!${args.sourceRange} to ${args.destinationSheetName}!${args.destinationRange}.`;
50
51
  }
51
52
  catch (error) {
52
53
  log.error(`Error copying formatting in spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -102,6 +102,7 @@ export function register(server) {
102
102
  columnProperties,
103
103
  });
104
104
  return JSON.stringify({
105
+ url: `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`,
105
106
  tableId: table.tableId,
106
107
  name: table.name,
107
108
  range: args.range,
@@ -28,7 +28,8 @@ export function register(server) {
28
28
  ],
29
29
  },
30
30
  });
31
- return `Chart ${args.chartId} deleted successfully.`;
31
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
32
+ return `${sheetUrl}\nChart ${args.chartId} deleted successfully.`;
32
33
  }
33
34
  catch (error) {
34
35
  log.error(`Error deleting chart: ${error.message || error}`);
@@ -30,7 +30,8 @@ export function register(server) {
30
30
  ],
31
31
  },
32
32
  });
33
- return `Successfully deleted sheet (ID: ${args.sheetId}) from spreadsheet.`;
33
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
34
+ return `${sheetUrl}\nSuccessfully deleted sheet (ID: ${args.sheetId}) from spreadsheet.`;
34
35
  }
35
36
  catch (error) {
36
37
  log.error(`Error deleting sheet in spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -35,6 +35,7 @@ export function register(server) {
35
35
  clearedRange = range;
36
36
  }
37
37
  return JSON.stringify({
38
+ url: `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`,
38
39
  tableId: table.tableId,
39
40
  name: table.name,
40
41
  deleted: true,
@@ -40,7 +40,8 @@ export function register(server) {
40
40
  if (!duplicatedSheet) {
41
41
  throw new UserError('Failed to duplicate sheet - no sheet properties returned.');
42
42
  }
43
- return `Successfully duplicated sheet as "${duplicatedSheet.title}" (Sheet ID: ${duplicatedSheet.sheetId}).`;
43
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
44
+ return `${sheetUrl}\nSuccessfully duplicated sheet as "${duplicatedSheet.title}" (Sheet ID: ${duplicatedSheet.sheetId}).`;
44
45
  }
45
46
  catch (error) {
46
47
  log.error(`Error duplicating sheet in spreadsheet ${args.spreadsheetId}: ${error.message || error}`);
@@ -93,7 +93,8 @@ export function register(server) {
93
93
  format.numberFormat = args.numberFormat;
94
94
  }
95
95
  await SheetsHelpers.formatCells(sheets, args.spreadsheetId, args.range, format);
96
- return `Successfully applied formatting to range "${args.range}".`;
96
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
97
+ return `${sheetUrl}\nSuccessfully applied formatting to range "${args.range}".`;
97
98
  }
98
99
  catch (error) {
99
100
  log.error(`Error formatting cells: ${error.message || error}`);
@@ -45,7 +45,8 @@ export function register(server) {
45
45
  ? 'unfroze columns'
46
46
  : `froze left ${args.frozenColumns} column(s)`);
47
47
  }
48
- return `Successfully ${parts.join(' and ')}.`;
48
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
49
+ return `${sheetUrl}\nSuccessfully ${parts.join(' and ')}.`;
49
50
  }
50
51
  catch (error) {
51
52
  log.error(`Error freezing rows/columns: ${error.message || error}`);
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getSheetsClient } from '../../clients.js';
4
4
  import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
5
+ import { trackRead } from '../../readTracker.js';
5
6
  export function register(server) {
6
7
  server.addTool({
7
8
  name: 'getSpreadsheetInfo',
@@ -16,6 +17,7 @@ export function register(server) {
16
17
  log.info(`Getting info for spreadsheet: ${args.spreadsheetId}`);
17
18
  try {
18
19
  const metadata = await SheetsHelpers.getSpreadsheetMetadata(sheets, args.spreadsheetId);
20
+ trackRead(args.spreadsheetId);
19
21
  const sheetList = metadata.sheets || [];
20
22
  return JSON.stringify({
21
23
  title: metadata.properties?.title || 'Untitled',
@@ -49,7 +49,8 @@ export function register(server) {
49
49
  spreadsheetId: args.spreadsheetId,
50
50
  requestBody: { requests },
51
51
  });
52
- return `Successfully created ${args.groups.length} row group(s).`;
52
+ const sheetUrl = `https://docs.google.com/spreadsheets/d/${args.spreadsheetId}/edit`;
53
+ return `${sheetUrl}\nSuccessfully created ${args.groups.length} row group(s).`;
53
54
  }
54
55
  catch (error) {
55
56
  log.error(`Error grouping rows: ${error.message || error}`);