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.
- package/dist/readTracker.js +145 -84
- package/dist/tools/docs/appendToGoogleDoc.js +7 -1
- package/dist/tools/docs/deleteRange.js +7 -1
- package/dist/tools/docs/findAndReplace.js +7 -1
- package/dist/tools/docs/modifyText.js +7 -1
- package/dist/tools/docs/readGoogleDoc.js +49 -3
- package/dist/tools/drive/addPermission.js +88 -0
- package/dist/tools/drive/index.js +8 -0
- package/dist/tools/drive/listPermissions.js +43 -0
- package/dist/tools/drive/removePermission.js +40 -0
- package/dist/tools/drive/updatePermission.js +50 -0
- package/dist/tools/gmail/messages.js +9 -7
- package/dist/tools/utils/appendMarkdownToGoogleDoc.js +7 -2
- package/dist/tools/utils/replaceDocumentWithMarkdown.js +7 -2
- package/package.json +2 -1
package/dist/readTracker.js
CHANGED
|
@@ -1,84 +1,145 @@
|
|
|
1
|
-
// In-memory tracker for read-before-edit guards (issue #18)
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
"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",
|