google-tools-mcp 1.2.4 → 1.2.6

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/auth.js CHANGED
@@ -54,6 +54,8 @@ const SCOPES = [
54
54
  'https://www.googleapis.com/auth/forms.responses.readonly',
55
55
  // Slides
56
56
  'https://www.googleapis.com/auth/presentations',
57
+ // Tasks
58
+ 'https://www.googleapis.com/auth/tasks',
57
59
  ];
58
60
 
59
61
  // ---------------------------------------------------------------------------
package/dist/clients.js CHANGED
@@ -14,6 +14,7 @@ let gmailClient = null;
14
14
  let calendarClient = null;
15
15
  let formsClient = null;
16
16
  let slidesClient = null;
17
+ let tasksClient = null;
17
18
 
18
19
  async function ensureAuth() {
19
20
  if (authClient) return;
@@ -46,6 +47,7 @@ async function reauthorize() {
46
47
  calendarClient = null;
47
48
  formsClient = null;
48
49
  slidesClient = null;
50
+ tasksClient = null;
49
51
  authClient = await authorize();
50
52
  logger.info('Re-authorization successful.');
51
53
  }
@@ -120,6 +122,7 @@ export function resetClients() {
120
122
  calendarClient = null;
121
123
  formsClient = null;
122
124
  slidesClient = null;
125
+ tasksClient = null;
123
126
  }
124
127
 
125
128
  /**
@@ -194,3 +197,17 @@ export async function getSlidesClient() {
194
197
  if (!slides) throw new UserError('Google Slides client is not initialized.');
195
198
  return slides;
196
199
  }
200
+
201
+ // --- Tasks client ---
202
+ export async function initializeTasksClient() {
203
+ if (tasksClient) return { authClient, tasksClient };
204
+ await ensureAuth();
205
+ if (!tasksClient) tasksClient = google.tasks({ version: 'v1', auth: authClient });
206
+ return { authClient, tasksClient };
207
+ }
208
+
209
+ export async function getTasksClient() {
210
+ const { tasksClient: tasks } = await initializeTasksClient();
211
+ if (!tasks) throw new UserError('Google Tasks client is not initialized.');
212
+ return tasks;
213
+ }
@@ -1,84 +1,145 @@
1
- // In-memory tracker for read-before-edit guards (issue #18).
2
- // Tracks which files have been read in this session and when.
3
- import { UserError } from 'fastmcp';
4
- import { getDriveClient } from './clients.js';
5
- import { logger } from './logger.js';
6
-
7
- // Map of fileId → { readAt: Date, modifiedTime: string (ISO) }
8
- const readLog = new Map();
9
-
10
- /**
11
- * Record that a file was read. Call from all read tools.
12
- */
13
- export function trackRead(fileId, modifiedTime) {
14
- readLog.set(fileId, {
15
- readAt: new Date(),
16
- modifiedTime: modifiedTime || null,
17
- });
18
- }
19
-
20
- /**
21
- * Guard a mutation. Call from all mutating tools before making changes.
22
- * Throws UserError if the file hasn't been read, or if it was modified externally.
23
- *
24
- * @param fileId - The file/document/spreadsheet ID
25
- * @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
26
- */
27
- export async function guardMutation(fileId, opts) {
28
- const entry = readLog.get(fileId);
29
- if (!entry) {
30
- throw new UserError(
31
- `This file (${fileId}) has not been read in this session. ` +
32
- 'Read it first before making changes to ensure you have current content. ' +
33
- 'Use readDocument, readSpreadsheet, readFile, or readDriveFile.'
34
- );
35
- }
36
-
37
- // Optionally check if file was modified externally since last read
38
- if (!opts?.skipExternalCheck) {
39
- try {
40
- const drive = await getDriveClient();
41
- const res = await drive.files.get({
42
- fileId,
43
- fields: 'modifiedTime',
44
- supportsAllDrives: true,
45
- });
46
- const currentModifiedTime = res.data.modifiedTime;
47
- if (entry.modifiedTime && currentModifiedTime !== entry.modifiedTime) {
48
- const readAt = entry.readAt.toISOString();
49
- throw new UserError(
50
- `This file was modified externally since you last read it ` +
51
- `(last read: ${readAt}, last modified: ${currentModifiedTime}). ` +
52
- 'Read the file again before editing to ensure you have current content.'
53
- );
54
- }
55
- // Update modifiedTime on successful check
56
- entry.modifiedTime = currentModifiedTime;
57
- } catch (error) {
58
- if (error instanceof UserError) throw error;
59
- // If we can't check, log warning but allow the mutation
60
- logger.warn(`Could not verify modifiedTime for ${fileId}: ${error.message}`);
61
- }
62
- }
63
- }
64
-
65
- /**
66
- * Update the read tracker after a successful mutation (so subsequent mutations
67
- * don't fail the external-change check against our own changes).
68
- */
69
- export function trackMutation(fileId) {
70
- const entry = readLog.get(fileId);
71
- if (entry) {
72
- entry.readAt = new Date();
73
- // Clear modifiedTime it will be stale after our mutation.
74
- // The next guardMutation call will fetch fresh modifiedTime.
75
- entry.modifiedTime = null;
76
- }
77
- }
78
-
79
- /**
80
- * Check if a file has been read (without throwing).
81
- */
82
- export function hasBeenRead(fileId) {
83
- return readLog.has(fileId);
84
- }
1
+ // In-memory tracker for read-before-edit guards (issue #18) and diff-aware
2
+ // reading/editing (issue #21).
3
+ // Tracks which files have been read in this session, when, and optionally the
4
+ // content snapshot at the time of the last read so blocked mutations can return
5
+ // a unified diff instead of a bare "modified externally" error.
6
+ import { UserError } from 'fastmcp';
7
+ import { createPatch } from 'diff';
8
+ import { getDriveClient } from './clients.js';
9
+ import { logger } from './logger.js';
10
+
11
+ // Map of fileId { readAt: Date, modifiedTime: string|null, content: string|null }
12
+ // content is only populated for file types that opt in (currently Google Docs).
13
+ // Sheets and raw Drive files still track read/modifiedTime only.
14
+ const readLog = new Map();
15
+
16
+ /**
17
+ * Record that a file was read. Call from all read tools.
18
+ * @param fileId
19
+ * @param modifiedTime Drive API modifiedTime (ISO string) at read time, or null
20
+ * @param content Optional content snapshot (e.g. markdown for docs) used for diffs
21
+ */
22
+ export function trackRead(fileId, modifiedTime, content) {
23
+ readLog.set(fileId, {
24
+ readAt: new Date(),
25
+ modifiedTime: modifiedTime || null,
26
+ content: typeof content === 'string' ? content : null,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Guard a mutation. Call from all mutating tools before making changes.
32
+ * Throws UserError if the file hasn't been read, or if it was modified externally.
33
+ *
34
+ * @param fileId - The file/document/spreadsheet ID
35
+ * @param opts.skipExternalCheck - If true, skip the Drive API modifiedTime check (for performance)
36
+ * @param opts.contentFetcher - Optional async () => string. If provided and an
37
+ * external-change conflict is detected, the fetcher is used to grab current
38
+ * content and the UserError message will include a unified diff plus rebase
39
+ * instructions rather than a plain error.
40
+ */
41
+ export async function guardMutation(fileId, opts) {
42
+ const entry = readLog.get(fileId);
43
+ if (!entry) {
44
+ throw new UserError(
45
+ `This file (${fileId}) has not been read in this session. ` +
46
+ 'Read it first before making changes to ensure you have current content. ' +
47
+ 'Use readDocument, readSpreadsheet, readFile, or readDriveFile.'
48
+ );
49
+ }
50
+
51
+ // Optionally check if file was modified externally since last read
52
+ if (!opts?.skipExternalCheck) {
53
+ try {
54
+ const drive = await getDriveClient();
55
+ const res = await drive.files.get({
56
+ fileId,
57
+ fields: 'modifiedTime',
58
+ supportsAllDrives: true,
59
+ });
60
+ const currentModifiedTime = res.data.modifiedTime;
61
+ if (entry.modifiedTime && currentModifiedTime !== entry.modifiedTime) {
62
+ const readAt = entry.readAt.toISOString();
63
+ // If we have a stored snapshot and the caller can fetch the
64
+ // current content, return a diff instead of a bare error so the
65
+ // model can rebase its edit on top of the new version.
66
+ if (entry.content && typeof opts?.contentFetcher === 'function') {
67
+ let currentContent;
68
+ try {
69
+ currentContent = await opts.contentFetcher();
70
+ } catch (fetchError) {
71
+ logger.warn(`contentFetcher failed for ${fileId}: ${fetchError.message}`);
72
+ }
73
+ if (typeof currentContent === 'string') {
74
+ const patch = createPatch(
75
+ fileId,
76
+ entry.content,
77
+ currentContent,
78
+ 'last read',
79
+ 'current',
80
+ { context: 3 }
81
+ );
82
+ // Refresh the snapshot so a subsequent read/mutation
83
+ // works against the new baseline.
84
+ entry.content = currentContent;
85
+ entry.modifiedTime = currentModifiedTime;
86
+ throw new UserError(
87
+ `This file was modified externally since you last read it ` +
88
+ `(last read: ${readAt}, last modified: ${currentModifiedTime}).\n\n` +
89
+ 'Do NOT re-apply your original edit blindly. Build your new edit on ' +
90
+ 'top of the current version. Below is a unified diff from your last ' +
91
+ 'read to the current document (old → current). If you need the full ' +
92
+ 'current content, call readDocument again.\n\n' +
93
+ '--- DIFF (last read → current) ---\n' +
94
+ patch +
95
+ '--- END DIFF ---'
96
+ );
97
+ }
98
+ }
99
+ throw new UserError(
100
+ `This file was modified externally since you last read it ` +
101
+ `(last read: ${readAt}, last modified: ${currentModifiedTime}). ` +
102
+ 'Read the file again before editing to ensure you have current content.'
103
+ );
104
+ }
105
+ // Update modifiedTime on successful check
106
+ entry.modifiedTime = currentModifiedTime;
107
+ } catch (error) {
108
+ if (error instanceof UserError) throw error;
109
+ // If we can't check, log warning but allow the mutation
110
+ logger.warn(`Could not verify modifiedTime for ${fileId}: ${error.message}`);
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Update the read tracker after a successful mutation (so subsequent mutations
117
+ * don't fail the external-change check against our own changes).
118
+ */
119
+ export function trackMutation(fileId) {
120
+ const entry = readLog.get(fileId);
121
+ if (entry) {
122
+ entry.readAt = new Date();
123
+ // Clear modifiedTime — it will be stale after our mutation.
124
+ // The next guardMutation call will fetch fresh modifiedTime.
125
+ entry.modifiedTime = null;
126
+ // Content is also stale after our mutation; clear so a future diff
127
+ // doesn't show our own edits as "external" changes.
128
+ entry.content = null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Check if a file has been read (without throwing).
134
+ */
135
+ export function hasBeenRead(fileId) {
136
+ return readLog.has(fileId);
137
+ }
138
+
139
+ /**
140
+ * Return the content snapshot from the last read, or null if none stored.
141
+ */
142
+ export function getLastReadContent(fileId) {
143
+ const entry = readLog.get(fileId);
144
+ return entry?.content ?? null;
145
+ }
@@ -4,6 +4,7 @@ import { z } from 'zod';
4
4
  import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, NotImplementedError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
+ import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
7
8
  import { guardMutation, trackMutation } from '../../readTracker.js';
8
9
  export function register(server) {
9
10
  server.addTool({
@@ -24,8 +25,13 @@ export function register(server) {
24
25
  .describe('The ID of the specific tab to append to. If not specified, appends to the first tab (or legacy document.body for documents without tabs).'),
25
26
  }),
26
27
  execute: async (args, { log }) => {
27
- await guardMutation(args.documentId);
28
28
  const docs = await getDocsClient();
29
+ await guardMutation(args.documentId, {
30
+ contentFetcher: async () => {
31
+ const current = await docs.documents.get({ documentId: args.documentId });
32
+ return docsJsonToMarkdown(current.data);
33
+ },
34
+ });
29
35
  // Resolve text content from filePath or inline parameter
30
36
  let text = args.text;
31
37
  if (args.filePath) {
@@ -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 { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
6
7
  import { guardMutation, trackMutation } from '../../readTracker.js';
7
8
  export function register(server) {
8
9
  server.addTool({
@@ -28,8 +29,13 @@ export function register(server) {
28
29
  path: ['endIndex'],
29
30
  }),
30
31
  execute: async (args, { log }) => {
31
- await guardMutation(args.documentId);
32
32
  const docs = await getDocsClient();
33
+ await guardMutation(args.documentId, {
34
+ contentFetcher: async () => {
35
+ const current = await docs.documents.get({ documentId: args.documentId });
36
+ return docsJsonToMarkdown(current.data);
37
+ },
38
+ });
33
39
  log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
34
40
  if (args.endIndex <= args.startIndex) {
35
41
  throw new UserError('End index must be greater than start index for deletion.');
@@ -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 { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
6
7
  import { guardMutation, trackMutation } from '../../readTracker.js';
7
8
  const FindAndReplaceParameters = DocumentIdParameter.extend({
8
9
  findText: z.string().min(1).describe('The text to search for in the document.'),
@@ -25,8 +26,13 @@ export function register(server) {
25
26
  'Returns the number of replacements made. Use an empty replaceText to delete all matches.',
26
27
  parameters: FindAndReplaceParameters,
27
28
  execute: async (args, { log }) => {
28
- await guardMutation(args.documentId);
29
29
  const docs = await getDocsClient();
30
+ await guardMutation(args.documentId, {
31
+ contentFetcher: async () => {
32
+ const current = await docs.documents.get({ documentId: args.documentId });
33
+ return docsJsonToMarkdown(current.data);
34
+ },
35
+ });
30
36
  log.info(`findAndReplace in doc ${args.documentId}: "${args.findText}" → "${args.replaceText}"` +
31
37
  `${args.matchCase ? ' (case-sensitive)' : ''}` +
32
38
  `${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
@@ -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 { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
6
7
  import { guardMutation, trackMutation } from '../../readTracker.js';
7
8
  const RangeTarget = z
8
9
  .object({
@@ -104,8 +105,13 @@ export function register(server) {
104
105
  'To add content to the end of a doc, use appendMarkdown or appendText.',
105
106
  parameters: ModifyTextParameters,
106
107
  execute: async (args, { log }) => {
107
- await guardMutation(args.documentId);
108
108
  const docs = await getDocsClient();
109
+ await guardMutation(args.documentId, {
110
+ contentFetcher: async () => {
111
+ const current = await docs.documents.get({ documentId: args.documentId });
112
+ return docsJsonToMarkdown(current.data);
113
+ },
114
+ });
109
115
  log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
110
116
  `${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
111
117
  `${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` +
@@ -1,14 +1,30 @@
1
1
  import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
+ import { createPatch } from 'diff';
3
4
  import { getDocsClient, getDriveClient } from '../../clients.js';
4
5
  import { DocumentIdParameter, NotImplementedError } from '../../types.js';
5
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
7
  import { docsJsonToMarkdown } from '../../markdown-transformer/index.js';
7
- import { trackRead } from '../../readTracker.js';
8
+ import { trackRead, getLastReadContent } from '../../readTracker.js';
9
+
10
+ async function fetchModifiedTime(documentId) {
11
+ try {
12
+ const drive = await getDriveClient();
13
+ const res = await drive.files.get({
14
+ fileId: documentId,
15
+ fields: 'modifiedTime',
16
+ supportsAllDrives: true,
17
+ });
18
+ return res.data.modifiedTime || null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
8
24
  export function register(server) {
9
25
  server.addTool({
10
26
  name: 'readDocument',
11
- description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown). Use format='text' for plain text, or format='json' for the raw document structure.",
27
+ description: "Reads the content of a Google Document. Returns markdown by default (formatted content suitable for editing and re-uploading with replaceDocumentWithMarkdown). Use format='text' for plain text, or format='json' for the raw document structure. Set diffFromLastRead=true (markdown only) to get a unified diff from your previous read in this session instead of the full content.",
12
28
  parameters: DocumentIdParameter.extend({
13
29
  format: z
14
30
  .enum(['text', 'json', 'markdown'])
@@ -23,6 +39,11 @@ export function register(server) {
23
39
  .string()
24
40
  .optional()
25
41
  .describe('The ID of the specific tab to read. If not specified, reads the first tab (or legacy document.body for documents without tabs).'),
42
+ diffFromLastRead: z
43
+ .boolean()
44
+ .optional()
45
+ .default(false)
46
+ .describe('If true and this document has already been read in this session (with format=markdown), returns a unified diff from the previous read to the current document instead of the full content. Ignored on first read or when format is not markdown.'),
26
47
  }),
27
48
  execute: async (args, { log }) => {
28
49
  const docs = await getDocsClient();
@@ -39,7 +60,7 @@ export function register(server) {
39
60
  fields: needsTabsContent ? '*' : fields, // Get full document if using tabs
40
61
  });
41
62
  log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
42
- trackRead(args.documentId);
63
+ const modifiedTime = await fetchModifiedTime(args.documentId);
43
64
  // If tabId is specified, find the specific tab
44
65
  let contentSource;
45
66
  if (args.tabId) {
@@ -58,6 +79,10 @@ export function register(server) {
58
79
  contentSource = res.data;
59
80
  }
60
81
  if (args.format === 'json') {
82
+ if (args.diffFromLastRead) {
83
+ log.info('diffFromLastRead ignored: only supported for format=markdown');
84
+ }
85
+ trackRead(args.documentId, modifiedTime);
61
86
  const jsonContent = JSON.stringify(contentSource, null, 2);
62
87
  // Apply length limit to JSON if specified
63
88
  if (args.maxLength && jsonContent.length > args.maxLength) {
@@ -70,6 +95,23 @@ export function register(server) {
70
95
  const markdownContent = docsJsonToMarkdown(contentSource);
71
96
  const totalLength = markdownContent.length;
72
97
  log.info(`Generated markdown: ${totalLength} characters`);
98
+ if (args.diffFromLastRead) {
99
+ const previous = getLastReadContent(args.documentId);
100
+ if (previous !== null) {
101
+ const patch = createPatch(
102
+ args.documentId,
103
+ previous,
104
+ markdownContent,
105
+ 'last read',
106
+ 'current',
107
+ { context: 3 }
108
+ );
109
+ trackRead(args.documentId, modifiedTime, markdownContent);
110
+ return patch;
111
+ }
112
+ log.info('diffFromLastRead requested but no prior snapshot exists; returning full content');
113
+ }
114
+ trackRead(args.documentId, modifiedTime, markdownContent);
73
115
  // Apply length limit to markdown if specified
74
116
  if (args.maxLength && totalLength > args.maxLength) {
75
117
  const truncatedContent = markdownContent.substring(0, args.maxLength);
@@ -78,6 +120,9 @@ export function register(server) {
78
120
  return markdownContent;
79
121
  }
80
122
  // Default: Text format - extract all text content
123
+ if (args.diffFromLastRead) {
124
+ log.info('diffFromLastRead ignored: only supported for format=markdown');
125
+ }
81
126
  let textContent = '';
82
127
  let elementCount = 0;
83
128
  // Process all content elements from contentSource
@@ -106,6 +151,7 @@ export function register(server) {
106
151
  });
107
152
  }
108
153
  });
154
+ trackRead(args.documentId, modifiedTime);
109
155
  if (!textContent.trim())
110
156
  return 'Document found, but appears empty.';
111
157
  const totalLength = textContent.length;
@@ -0,0 +1,88 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'addPermission',
7
+ description: 'Shares a Drive file or folder by adding a new sharing permission. Supports sharing with a user, group, entire domain, or anyone with the link. Use listPermissions to see existing shares, updatePermission to change a role, or removePermission to revoke access.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the file or folder to share.'),
10
+ type: z
11
+ .enum(['user', 'group', 'domain', 'anyone'])
12
+ .describe("Grantee type. 'user' and 'group' require emailAddress; 'domain' requires domain; 'anyone' is link sharing."),
13
+ role: z
14
+ .enum(['reader', 'commenter', 'writer', 'fileOrganizer', 'organizer', 'owner'])
15
+ .describe('Access level granted. Use owner only with transferOwnership=true.'),
16
+ emailAddress: z
17
+ .string()
18
+ .optional()
19
+ .describe("Email address of the user or group (required when type is 'user' or 'group')."),
20
+ domain: z
21
+ .string()
22
+ .optional()
23
+ .describe("Domain to share with (required when type is 'domain')."),
24
+ sendNotificationEmail: z
25
+ .boolean()
26
+ .optional()
27
+ .describe('Whether Google should send a notification email (default: true for user/group, false for domain/anyone).'),
28
+ emailMessage: z
29
+ .string()
30
+ .optional()
31
+ .describe('Custom message to include in the notification email (only applies when sendNotificationEmail is true).'),
32
+ transferOwnership: z
33
+ .boolean()
34
+ .optional()
35
+ .default(false)
36
+ .describe("Set to true when transferring ownership (role must be 'owner')."),
37
+ allowFileDiscovery: z
38
+ .boolean()
39
+ .optional()
40
+ .describe("For type 'domain' or 'anyone': whether the file can be discovered via search. Defaults to false (link-only)."),
41
+ }),
42
+ execute: async (args, { log }) => {
43
+ if ((args.type === 'user' || args.type === 'group') && !args.emailAddress) {
44
+ throw new UserError(`emailAddress is required when type is '${args.type}'.`);
45
+ }
46
+ if (args.type === 'domain' && !args.domain) {
47
+ throw new UserError("domain is required when type is 'domain'.");
48
+ }
49
+ if (args.role === 'owner' && !args.transferOwnership) {
50
+ throw new UserError("Role 'owner' requires transferOwnership=true.");
51
+ }
52
+ const drive = await getDriveClient();
53
+ log.info(`Adding permission to ${args.fileId}: type=${args.type}, role=${args.role}`);
54
+ const requestBody = {
55
+ type: args.type,
56
+ role: args.role,
57
+ };
58
+ if (args.emailAddress) requestBody.emailAddress = args.emailAddress;
59
+ if (args.domain) requestBody.domain = args.domain;
60
+ if (args.allowFileDiscovery !== undefined) {
61
+ requestBody.allowFileDiscovery = args.allowFileDiscovery;
62
+ }
63
+ const defaultNotify = args.type === 'user' || args.type === 'group';
64
+ const sendNotify = args.sendNotificationEmail ?? defaultNotify;
65
+ try {
66
+ const response = await drive.permissions.create({
67
+ fileId: args.fileId,
68
+ requestBody,
69
+ sendNotificationEmail: sendNotify,
70
+ emailMessage: sendNotify ? args.emailMessage : undefined,
71
+ transferOwnership: args.transferOwnership,
72
+ supportsAllDrives: true,
73
+ fields: 'id,type,role,emailAddress,domain,displayName,allowFileDiscovery,pendingOwner',
74
+ });
75
+ return JSON.stringify(response.data, null, 2);
76
+ }
77
+ catch (error) {
78
+ log.error(`Error adding permission: ${error.message || error}`);
79
+ if (error.code === 404)
80
+ throw new UserError(`File not found (ID: ${args.fileId}).`);
81
+ if (error.code === 403)
82
+ throw new UserError('Permission denied. You need writer+ access (or be the owner) to share this file.');
83
+ const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
84
+ throw new UserError(`Failed to add permission: ${apiMsg}`);
85
+ }
86
+ },
87
+ });
88
+ }
@@ -15,6 +15,10 @@ 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
17
  import { register as uploadFile } from './uploadFile.js';
18
+ import { register as listPermissions } from './listPermissions.js';
19
+ import { register as addPermission } from './addPermission.js';
20
+ import { register as removePermission } from './removePermission.js';
21
+ import { register as updatePermission } from './updatePermission.js';
18
22
  export function registerDriveTools(server) {
19
23
  listDriveFiles(server);
20
24
  searchGoogleDocs(server);
@@ -33,4 +37,8 @@ export function registerDriveTools(server) {
33
37
  downloadFile(server);
34
38
  getFilePath(server);
35
39
  uploadFile(server);
40
+ listPermissions(server);
41
+ addPermission(server);
42
+ removePermission(server);
43
+ updatePermission(server);
36
44
  }
@@ -0,0 +1,43 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'listPermissions',
7
+ description: 'Lists all sharing permissions on a Drive file or folder. Shows who the file is shared with (users, groups, domains, anyone) along with their roles (reader, commenter, writer, owner, etc.). Use addPermission/updatePermission/removePermission to change sharing.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the file or folder whose permissions to list.'),
10
+ }),
11
+ execute: async (args, { log }) => {
12
+ const drive = await getDriveClient();
13
+ log.info(`Listing permissions for file: ${args.fileId}`);
14
+ try {
15
+ const response = await drive.permissions.list({
16
+ fileId: args.fileId,
17
+ fields: 'permissions(id,type,role,emailAddress,domain,displayName,allowFileDiscovery,deleted,pendingOwner)',
18
+ supportsAllDrives: true,
19
+ });
20
+ const permissions = (response.data.permissions || []).map((p) => ({
21
+ id: p.id,
22
+ type: p.type,
23
+ role: p.role,
24
+ emailAddress: p.emailAddress || null,
25
+ domain: p.domain || null,
26
+ displayName: p.displayName || null,
27
+ allowFileDiscovery: p.allowFileDiscovery ?? null,
28
+ deleted: p.deleted ?? false,
29
+ pendingOwner: p.pendingOwner ?? false,
30
+ }));
31
+ return JSON.stringify({ fileId: args.fileId, permissions }, null, 2);
32
+ }
33
+ catch (error) {
34
+ log.error(`Error listing permissions: ${error.message || error}`);
35
+ if (error.code === 404)
36
+ throw new UserError(`File not found (ID: ${args.fileId}).`);
37
+ if (error.code === 403)
38
+ throw new UserError('Permission denied. You need access to the file to list its permissions.');
39
+ throw new UserError(`Failed to list permissions: ${error.message || 'Unknown error'}`);
40
+ }
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,40 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'removePermission',
7
+ description: 'Revokes a specific sharing permission from a Drive file or folder. Use listPermissions first to get the permissionId you want to remove.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the file or folder.'),
10
+ permissionId: z
11
+ .string()
12
+ .describe('The ID of the permission to remove (from listPermissions).'),
13
+ }),
14
+ execute: async (args, { log }) => {
15
+ const drive = await getDriveClient();
16
+ log.info(`Removing permission ${args.permissionId} from file ${args.fileId}`);
17
+ try {
18
+ await drive.permissions.delete({
19
+ fileId: args.fileId,
20
+ permissionId: args.permissionId,
21
+ supportsAllDrives: true,
22
+ });
23
+ return JSON.stringify({
24
+ fileId: args.fileId,
25
+ permissionId: args.permissionId,
26
+ removed: true,
27
+ }, null, 2);
28
+ }
29
+ catch (error) {
30
+ log.error(`Error removing permission: ${error.message || error}`);
31
+ if (error.code === 404)
32
+ throw new UserError(`Permission or file not found (fileId: ${args.fileId}, permissionId: ${args.permissionId}).`);
33
+ if (error.code === 403)
34
+ throw new UserError('Permission denied. You need writer+ access (or be the owner) to modify sharing.');
35
+ const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
36
+ throw new UserError(`Failed to remove permission: ${apiMsg}`);
37
+ }
38
+ },
39
+ });
40
+ }
@@ -0,0 +1,50 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ export function register(server) {
5
+ server.addTool({
6
+ name: 'updatePermission',
7
+ description: 'Changes the role (access level) of an existing sharing permission. Use listPermissions first to get the permissionId.',
8
+ parameters: z.object({
9
+ fileId: z.string().describe('The ID of the file or folder.'),
10
+ permissionId: z
11
+ .string()
12
+ .describe('The ID of the permission to update (from listPermissions).'),
13
+ role: z
14
+ .enum(['reader', 'commenter', 'writer', 'fileOrganizer', 'organizer', 'owner'])
15
+ .describe('New access level. Use owner only with transferOwnership=true.'),
16
+ transferOwnership: z
17
+ .boolean()
18
+ .optional()
19
+ .default(false)
20
+ .describe("Set to true when transferring ownership (role must be 'owner')."),
21
+ }),
22
+ execute: async (args, { log }) => {
23
+ if (args.role === 'owner' && !args.transferOwnership) {
24
+ throw new UserError("Role 'owner' requires transferOwnership=true.");
25
+ }
26
+ const drive = await getDriveClient();
27
+ log.info(`Updating permission ${args.permissionId} on ${args.fileId} to role=${args.role}`);
28
+ try {
29
+ const response = await drive.permissions.update({
30
+ fileId: args.fileId,
31
+ permissionId: args.permissionId,
32
+ requestBody: { role: args.role },
33
+ transferOwnership: args.transferOwnership,
34
+ supportsAllDrives: true,
35
+ fields: 'id,type,role,emailAddress,domain,displayName,allowFileDiscovery,pendingOwner',
36
+ });
37
+ return JSON.stringify(response.data, null, 2);
38
+ }
39
+ catch (error) {
40
+ log.error(`Error updating permission: ${error.message || error}`);
41
+ if (error.code === 404)
42
+ throw new UserError(`Permission or file not found (fileId: ${args.fileId}, permissionId: ${args.permissionId}).`);
43
+ if (error.code === 403)
44
+ throw new UserError('Permission denied. You need writer+ access (or be the owner) to modify sharing.');
45
+ const apiMsg = error.response?.data?.error?.message || error.message || 'Unknown error';
46
+ throw new UserError(`Failed to update permission: ${apiMsg}`);
47
+ }
48
+ },
49
+ });
50
+ }
@@ -178,6 +178,12 @@ const CATEGORIES = {
178
178
  registerSlidesTools(server);
179
179
  },
180
180
  },
181
+ tasks: {
182
+ async loader(server) {
183
+ const { registerTasksTools } = await import('./tasks/index.js');
184
+ registerTasksTools(server);
185
+ },
186
+ },
181
187
  };
182
188
 
183
189
  // ---------------------------------------------------------------------------
@@ -0,0 +1,43 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'completeTask',
8
+ description: 'Marks a task as completed.',
9
+ parameters: z.object({
10
+ taskListId: z.string().describe('ID of the task list containing the task'),
11
+ taskId: z.string().describe('ID of the task to complete'),
12
+ }),
13
+ execute: async (args, { log }) => {
14
+ const tasks = await getTasksClient();
15
+ log.info(`Completing task ${args.taskId} in list: ${args.taskListId}`);
16
+
17
+ try {
18
+ const response = await tasks.tasks.patch({
19
+ tasklist: args.taskListId,
20
+ task: args.taskId,
21
+ requestBody: { status: 'completed', completed: new Date().toISOString() },
22
+ });
23
+ const t = response.data;
24
+ return JSON.stringify({
25
+ id: t.id,
26
+ taskListId: args.taskListId,
27
+ title: t.title || '',
28
+ notes: t.notes || null,
29
+ status: t.status,
30
+ due: t.due || null,
31
+ completed: t.completed || null,
32
+ }, null, 2);
33
+ } catch (error) {
34
+ log.error(`Error completing task: ${error.message || error}`);
35
+ if (error.code === 404)
36
+ throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
37
+ if (error.code === 403)
38
+ throw new UserError('Permission denied. Make sure you have access to this task.');
39
+ throw new UserError(`Failed to complete task: ${error.message || 'Unknown error'}`);
40
+ }
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,53 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'createTask',
8
+ description: 'Creates a new task in a Google Tasks task list.',
9
+ parameters: z.object({
10
+ taskListId: z.string().describe('ID of the task list to add the task to'),
11
+ title: z.string().describe('Title of the task'),
12
+ notes: z.string().optional().describe('Optional notes or description for the task'),
13
+ due: z
14
+ .string()
15
+ .optional()
16
+ .describe('Optional due date in RFC 3339 or plain date format (e.g. "2025-04-30")'),
17
+ }),
18
+ execute: async (args, { log }) => {
19
+ const tasks = await getTasksClient();
20
+ log.info(`Creating task "${args.title}" in list: ${args.taskListId}`);
21
+
22
+ try {
23
+ const body = { title: args.title };
24
+ if (args.notes) body.notes = args.notes;
25
+ if (args.due) body.due = normalizeDate(args.due);
26
+
27
+ const response = await tasks.tasks.insert({ tasklist: args.taskListId, requestBody: body });
28
+ const t = response.data;
29
+ return JSON.stringify({
30
+ id: t.id,
31
+ taskListId: args.taskListId,
32
+ title: t.title || '',
33
+ notes: t.notes || null,
34
+ status: t.status,
35
+ due: t.due || null,
36
+ completed: t.completed || null,
37
+ }, null, 2);
38
+ } catch (error) {
39
+ log.error(`Error creating task: ${error.message || error}`);
40
+ if (error.code === 404)
41
+ throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
42
+ if (error.code === 403)
43
+ throw new UserError('Permission denied. Make sure you have access to this task list.');
44
+ throw new UserError(`Failed to create task: ${error.message || 'Unknown error'}`);
45
+ }
46
+ },
47
+ });
48
+ }
49
+
50
+ function normalizeDate(value) {
51
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
52
+ return value;
53
+ }
@@ -0,0 +1,25 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'createTaskList',
8
+ description: 'Creates a new Google Tasks task list with the given title.',
9
+ parameters: z.object({
10
+ title: z.string().describe('Title for the new task list'),
11
+ }),
12
+ execute: async (args, { log }) => {
13
+ const tasks = await getTasksClient();
14
+ log.info(`Creating task list: ${args.title}`);
15
+
16
+ try {
17
+ const response = await tasks.tasklists.insert({ requestBody: { title: args.title } });
18
+ return JSON.stringify({ id: response.data.id, title: response.data.title }, null, 2);
19
+ } catch (error) {
20
+ log.error(`Error creating task list: ${error.message || error}`);
21
+ throw new UserError(`Failed to create task list: ${error.message || 'Unknown error'}`);
22
+ }
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,30 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'deleteTask',
8
+ description: 'Permanently deletes a task.',
9
+ parameters: z.object({
10
+ taskListId: z.string().describe('ID of the task list containing the task'),
11
+ taskId: z.string().describe('ID of the task to delete'),
12
+ }),
13
+ execute: async (args, { log }) => {
14
+ const tasks = await getTasksClient();
15
+ log.info(`Deleting task ${args.taskId} from list: ${args.taskListId}`);
16
+
17
+ try {
18
+ await tasks.tasks.delete({ tasklist: args.taskListId, task: args.taskId });
19
+ return JSON.stringify({ deleted: true, taskId: args.taskId, taskListId: args.taskListId }, null, 2);
20
+ } catch (error) {
21
+ log.error(`Error deleting task: ${error.message || error}`);
22
+ if (error.code === 404)
23
+ throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
24
+ if (error.code === 403)
25
+ throw new UserError('Permission denied. Make sure you have access to this task.');
26
+ throw new UserError(`Failed to delete task: ${error.message || 'Unknown error'}`);
27
+ }
28
+ },
29
+ });
30
+ }
@@ -0,0 +1,29 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'deleteTaskList',
8
+ description: 'Permanently deletes a task list and all tasks within it.',
9
+ parameters: z.object({
10
+ taskListId: z.string().describe('ID of the task list to delete'),
11
+ }),
12
+ execute: async (args, { log }) => {
13
+ const tasks = await getTasksClient();
14
+ log.info(`Deleting task list: ${args.taskListId}`);
15
+
16
+ try {
17
+ await tasks.tasklists.delete({ tasklist: args.taskListId });
18
+ return JSON.stringify({ deleted: true, taskListId: args.taskListId }, null, 2);
19
+ } catch (error) {
20
+ log.error(`Error deleting task list: ${error.message || error}`);
21
+ if (error.code === 404)
22
+ throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
23
+ if (error.code === 403)
24
+ throw new UserError('Permission denied. You can only delete task lists you own.');
25
+ throw new UserError(`Failed to delete task list: ${error.message || 'Unknown error'}`);
26
+ }
27
+ },
28
+ });
29
+ }
@@ -0,0 +1,19 @@
1
+ import { register as listTaskLists } from './listTaskLists.js';
2
+ import { register as createTaskList } from './createTaskList.js';
3
+ import { register as deleteTaskList } from './deleteTaskList.js';
4
+ import { register as listTasks } from './listTasks.js';
5
+ import { register as createTask } from './createTask.js';
6
+ import { register as updateTask } from './updateTask.js';
7
+ import { register as completeTask } from './completeTask.js';
8
+ import { register as deleteTask } from './deleteTask.js';
9
+
10
+ export function registerTasksTools(server) {
11
+ listTaskLists(server);
12
+ createTaskList(server);
13
+ deleteTaskList(server);
14
+ listTasks(server);
15
+ createTask(server);
16
+ updateTask(server);
17
+ completeTask(server);
18
+ deleteTask(server);
19
+ }
@@ -0,0 +1,26 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'listTaskLists',
8
+ description: 'Lists all Google Task lists for the authenticated user. Returns the id and title of each list.',
9
+ parameters: z.object({}),
10
+ execute: async (_args, { log }) => {
11
+ const tasks = await getTasksClient();
12
+ log.info('Listing task lists');
13
+
14
+ try {
15
+ const response = await tasks.tasklists.list();
16
+ const lists = response.data.items || [];
17
+ return JSON.stringify(lists.map((l) => ({ id: l.id, title: l.title })), null, 2);
18
+ } catch (error) {
19
+ log.error(`Error listing task lists: ${error.message || error}`);
20
+ if (error.code === 401)
21
+ throw new UserError('Authentication failed. Try logging out and re-authenticating.');
22
+ throw new UserError(`Failed to list task lists: ${error.message || 'Unknown error'}`);
23
+ }
24
+ },
25
+ });
26
+ }
@@ -0,0 +1,69 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'listTasks',
8
+ description:
9
+ 'Lists tasks in a Google Tasks task list. Optionally filter by completion status or due date range.',
10
+ parameters: z.object({
11
+ taskListId: z.string().describe('ID of the task list to read from'),
12
+ showCompleted: z
13
+ .boolean()
14
+ .optional()
15
+ .describe('Whether to include completed tasks (default: true)'),
16
+ dueMin: z
17
+ .string()
18
+ .optional()
19
+ .describe('Lower bound for due date filter, RFC 3339 or plain date (e.g. "2025-04-01")'),
20
+ dueMax: z
21
+ .string()
22
+ .optional()
23
+ .describe('Upper bound for due date filter, RFC 3339 or plain date (e.g. "2025-04-30")'),
24
+ }),
25
+ execute: async (args, { log }) => {
26
+ const tasks = await getTasksClient();
27
+ const showCompleted = args.showCompleted ?? true;
28
+ log.info(`Listing tasks in list: ${args.taskListId}`);
29
+
30
+ try {
31
+ const params = {
32
+ tasklist: args.taskListId,
33
+ showCompleted,
34
+ showHidden: showCompleted,
35
+ };
36
+ if (args.dueMin) params.dueMin = normalizeDate(args.dueMin);
37
+ if (args.dueMax) params.dueMax = normalizeDate(args.dueMax);
38
+
39
+ const response = await tasks.tasks.list(params);
40
+ const items = response.data.items || [];
41
+ return JSON.stringify(items.map((t) => formatTask(t, args.taskListId)), null, 2);
42
+ } catch (error) {
43
+ log.error(`Error listing tasks: ${error.message || error}`);
44
+ if (error.code === 404)
45
+ throw new UserError(`Task list not found (ID: ${args.taskListId}).`);
46
+ if (error.code === 403)
47
+ throw new UserError('Permission denied. Make sure you have access to this task list.');
48
+ throw new UserError(`Failed to list tasks: ${error.message || 'Unknown error'}`);
49
+ }
50
+ },
51
+ });
52
+ }
53
+
54
+ function formatTask(t, taskListId) {
55
+ return {
56
+ id: t.id,
57
+ taskListId,
58
+ title: t.title || '',
59
+ notes: t.notes || null,
60
+ status: t.status,
61
+ due: t.due || null,
62
+ completed: t.completed || null,
63
+ };
64
+ }
65
+
66
+ function normalizeDate(value) {
67
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
68
+ return value;
69
+ }
@@ -0,0 +1,68 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getTasksClient } from '../../clients.js';
4
+
5
+ export function register(server) {
6
+ server.addTool({
7
+ name: 'updateTask',
8
+ description:
9
+ 'Updates fields on an existing task. Only provided fields are changed; omitted fields are left as-is.',
10
+ parameters: z.object({
11
+ taskListId: z.string().describe('ID of the task list containing the task'),
12
+ taskId: z.string().describe('ID of the task to update'),
13
+ title: z.string().optional().describe('New title for the task'),
14
+ notes: z.string().optional().describe('New notes for the task'),
15
+ due: z
16
+ .string()
17
+ .optional()
18
+ .describe('New due date in RFC 3339 or plain date format (e.g. "2025-04-30")'),
19
+ status: z
20
+ .enum(['needsAction', 'completed'])
21
+ .optional()
22
+ .describe('New status for the task'),
23
+ }),
24
+ execute: async (args, { log }) => {
25
+ const tasks = await getTasksClient();
26
+ log.info(`Updating task ${args.taskId} in list: ${args.taskListId}`);
27
+
28
+ try {
29
+ const body = {};
30
+ if (args.title !== undefined) body.title = args.title;
31
+ if (args.notes !== undefined) body.notes = args.notes;
32
+ if (args.due !== undefined) body.due = normalizeDate(args.due);
33
+ if (args.status !== undefined) {
34
+ body.status = args.status;
35
+ if (args.status === 'completed') body.completed = new Date().toISOString();
36
+ }
37
+
38
+ const response = await tasks.tasks.patch({
39
+ tasklist: args.taskListId,
40
+ task: args.taskId,
41
+ requestBody: body,
42
+ });
43
+ const t = response.data;
44
+ return JSON.stringify({
45
+ id: t.id,
46
+ taskListId: args.taskListId,
47
+ title: t.title || '',
48
+ notes: t.notes || null,
49
+ status: t.status,
50
+ due: t.due || null,
51
+ completed: t.completed || null,
52
+ }, null, 2);
53
+ } catch (error) {
54
+ log.error(`Error updating task: ${error.message || error}`);
55
+ if (error.code === 404)
56
+ throw new UserError(`Task not found (taskId: ${args.taskId}, listId: ${args.taskListId}).`);
57
+ if (error.code === 403)
58
+ throw new UserError('Permission denied. Make sure you have access to this task.');
59
+ throw new UserError(`Failed to update task: ${error.message || 'Unknown error'}`);
60
+ }
61
+ },
62
+ });
63
+ }
64
+
65
+ function normalizeDate(value) {
66
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return `${value}T00:00:00.000Z`;
67
+ return value;
68
+ }
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
- import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
7
+ import { insertMarkdown, formatInsertResult, docsJsonToMarkdown } from '../../markdown-transformer/index.js';
8
8
  import { guardMutation, trackMutation } from '../../readTracker.js';
9
9
  export function register(server) {
10
10
  server.addTool({
@@ -32,8 +32,13 @@ export function register(server) {
32
32
  .describe('If true, the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title.'),
33
33
  }),
34
34
  execute: async (args, { log }) => {
35
- await guardMutation(args.documentId);
36
35
  const docs = await getDocsClient();
36
+ await guardMutation(args.documentId, {
37
+ contentFetcher: async () => {
38
+ const current = await docs.documents.get({ documentId: args.documentId });
39
+ return docsJsonToMarkdown(current.data);
40
+ },
41
+ });
37
42
  // Resolve markdown content from filePath or inline parameter
38
43
  let markdown = args.markdown;
39
44
  if (args.filePath) {
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { getDocsClient } from '../../clients.js';
5
5
  import { DocumentIdParameter, MarkdownConversionError } from '../../types.js';
6
6
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
7
- import { insertMarkdown, formatInsertResult } from '../../markdown-transformer/index.js';
7
+ import { insertMarkdown, formatInsertResult, docsJsonToMarkdown } from '../../markdown-transformer/index.js';
8
8
  import { guardMutation, trackMutation } from '../../readTracker.js';
9
9
  export function register(server) {
10
10
  server.addTool({
@@ -33,8 +33,13 @@ export function register(server) {
33
33
  .describe('If true (default), the first H1 heading (# ...) in the markdown is styled as a Google Docs TITLE instead of Heading 1. Useful when the markdown represents a full document whose first line is the document title. Set to false if the first H1 should remain a Heading 1.'),
34
34
  }),
35
35
  execute: async (args, { log }) => {
36
- await guardMutation(args.documentId);
37
36
  const docs = await getDocsClient();
37
+ await guardMutation(args.documentId, {
38
+ contentFetcher: async () => {
39
+ const current = await docs.documents.get({ documentId: args.documentId });
40
+ return docsJsonToMarkdown(current.data);
41
+ },
42
+ });
38
43
  // Resolve markdown content from filePath or inline parameter
39
44
  let markdown = args.markdown;
40
45
  if (args.filePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "The easiest MCP server for Google Workspace — Drive, Docs, Sheets, Gmail, Calendar, and Forms. 153 tools with one-click browser auth. Read Word docs, PDFs, and spreadsheets straight from Drive.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -64,6 +64,7 @@
64
64
  "dependencies": {
65
65
  "@clack/prompts": "^1.2.0",
66
66
  "chalk": "^5.6.2",
67
+ "diff": "^7.0.0",
67
68
  "fastmcp": "^3.24.0",
68
69
  "google-auth-library": "^10.5.0",
69
70
  "googleapis": "^171.4.0",