google-tools-mcp 1.2.3 → 1.2.5

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.
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  // Gmail Message tools
2
2
  import { z } from 'zod';
3
3
  import { getGmailClient } from '../../clients.js';
4
- import { processMessagePart, constructRawMessage, constructRawMessageWithAttachments, findHeader, formatEmailList, getNestedHistory, getPlainTextBody, wrapTextBody } from '../../helpers.js';
4
+ import { processMessagePart, constructRawMessage, constructRawMessageWithAttachments, findHeader, formatEmailList, getNestedHistory, getPlainTextBody, isHtmlBody, wrapTextBody } from '../../helpers.js';
5
5
 
6
6
  export function register(server) {
7
7
  server.addTool({
@@ -117,6 +117,7 @@ export function register(server) {
117
117
  msgHeaders.push(`Subject: ${subject}`);
118
118
  if (messageIdHeader) msgHeaders.push(`In-Reply-To: ${messageIdHeader}`);
119
119
  if (references.length) msgHeaders.push(`References: ${references.join(' ')}`);
120
+ const htmlMode = isHtmlBody(params.body);
120
121
  let raw;
121
122
  if (params.attachments?.length) {
122
123
  const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
@@ -125,7 +126,7 @@ export function register(server) {
125
126
  const parts = [];
126
127
  parts.push([
127
128
  `--${boundary}`,
128
- 'Content-Type: text/plain; charset="UTF-8"',
129
+ `Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`,
129
130
  'Content-Transfer-Encoding: base64',
130
131
  '',
131
132
  Buffer.from(fullBody).toString('base64'),
@@ -143,10 +144,10 @@ export function register(server) {
143
144
  const rawStr = [msgHeaders.join('\r\n'), '', parts.join('\r\n'), `--${boundary}--`].join('\r\n');
144
145
  raw = Buffer.from(rawStr).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
145
146
  } else {
146
- msgHeaders.push('Content-Type: text/plain; charset="UTF-8"');
147
+ msgHeaders.push(`Content-Type: ${htmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`);
147
148
  msgHeaders.push('Content-Transfer-Encoding: quoted-printable');
148
149
  msgHeaders.push('MIME-Version: 1.0');
149
- const rawStr = [msgHeaders.join('\r\n'), '', wrapTextBody(fullBody)].join('\r\n');
150
+ const rawStr = [msgHeaders.join('\r\n'), '', htmlMode ? fullBody : wrapTextBody(fullBody)].join('\r\n');
150
151
  raw = Buffer.from(rawStr).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
151
152
  }
152
153
  const { data } = await gmail.users.messages.send({
@@ -232,6 +233,7 @@ export function register(server) {
232
233
  if (params.cc?.length) msgHeaders.push(`Cc: ${params.cc.join(', ')}`);
233
234
  if (params.bcc?.length) msgHeaders.push(`Bcc: ${params.bcc.join(', ')}`);
234
235
  msgHeaders.push(`Subject: ${subject}`);
236
+ const fwdHtmlMode = params.body && isHtmlBody(params.body);
235
237
  let raw;
236
238
  if (attachmentParts.length) {
237
239
  const boundary = `boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
@@ -240,7 +242,7 @@ export function register(server) {
240
242
  const parts = [];
241
243
  parts.push([
242
244
  `--${boundary}`,
243
- 'Content-Type: text/plain; charset="UTF-8"',
245
+ `Content-Type: ${fwdHtmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`,
244
246
  'Content-Transfer-Encoding: base64',
245
247
  '',
246
248
  Buffer.from(fullBody).toString('base64'),
@@ -258,10 +260,10 @@ export function register(server) {
258
260
  const rawStr = [msgHeaders.join('\r\n'), '', parts.join('\r\n'), `--${boundary}--`].join('\r\n');
259
261
  raw = Buffer.from(rawStr).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
260
262
  } else {
261
- msgHeaders.push('Content-Type: text/plain; charset="UTF-8"');
263
+ msgHeaders.push(`Content-Type: ${fwdHtmlMode ? 'text/html' : 'text/plain'}; charset="UTF-8"`);
262
264
  msgHeaders.push('Content-Transfer-Encoding: quoted-printable');
263
265
  msgHeaders.push('MIME-Version: 1.0');
264
- const rawStr = [msgHeaders.join('\r\n'), '', wrapTextBody(fullBody)].join('\r\n');
266
+ const rawStr = [msgHeaders.join('\r\n'), '', fwdHtmlMode ? fullBody : wrapTextBody(fullBody)].join('\r\n');
265
267
  raw = Buffer.from(rawStr).toString('base64url').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
266
268
  }
267
269
  const { data } = await gmail.users.messages.send({
@@ -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.3",
3
+ "version": "1.2.5",
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",