google-tools-mcp 1.0.4 → 1.0.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.
@@ -0,0 +1,268 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient } from '../../clients.js';
4
+ import * as fs from 'fs/promises';
5
+ import * as path from 'path';
6
+ import { Readable } from 'stream';
7
+ import { pipeline } from 'stream/promises';
8
+ import { createWriteStream } from 'fs';
9
+
10
+ // Google Workspace MIME types and their export formats
11
+ const EXPORT_FORMATS = {
12
+ 'application/vnd.google-apps.document': {
13
+ label: 'Google Doc',
14
+ formats: {
15
+ pdf: 'application/pdf',
16
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
17
+ md: 'text/markdown',
18
+ txt: 'text/plain',
19
+ html: 'text/html',
20
+ rtf: 'application/rtf',
21
+ odt: 'application/vnd.oasis.opendocument.text',
22
+ epub: 'application/epub+zip',
23
+ },
24
+ defaultFormat: 'pdf',
25
+ extensions: {
26
+ pdf: '.pdf',
27
+ docx: '.docx',
28
+ md: '.md',
29
+ txt: '.txt',
30
+ html: '.html',
31
+ rtf: '.rtf',
32
+ odt: '.odt',
33
+ epub: '.epub',
34
+ },
35
+ },
36
+ 'application/vnd.google-apps.spreadsheet': {
37
+ label: 'Google Sheet',
38
+ formats: {
39
+ pdf: 'application/pdf',
40
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
41
+ csv: 'text/csv',
42
+ tsv: 'text/tab-separated-values',
43
+ ods: 'application/vnd.oasis.opendocument.spreadsheet',
44
+ },
45
+ defaultFormat: 'xlsx',
46
+ extensions: {
47
+ pdf: '.pdf',
48
+ xlsx: '.xlsx',
49
+ csv: '.csv',
50
+ tsv: '.tsv',
51
+ ods: '.ods',
52
+ },
53
+ },
54
+ 'application/vnd.google-apps.presentation': {
55
+ label: 'Google Slides',
56
+ formats: {
57
+ pdf: 'application/pdf',
58
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
59
+ odp: 'application/vnd.oasis.opendocument.presentation',
60
+ txt: 'text/plain',
61
+ },
62
+ defaultFormat: 'pdf',
63
+ extensions: {
64
+ pdf: '.pdf',
65
+ pptx: '.pptx',
66
+ odp: '.odp',
67
+ txt: '.txt',
68
+ },
69
+ },
70
+ 'application/vnd.google-apps.drawing': {
71
+ label: 'Google Drawing',
72
+ formats: {
73
+ pdf: 'application/pdf',
74
+ png: 'image/png',
75
+ jpg: 'image/jpeg',
76
+ svg: 'image/svg+xml',
77
+ },
78
+ defaultFormat: 'pdf',
79
+ extensions: {
80
+ pdf: '.pdf',
81
+ png: '.png',
82
+ jpg: '.jpg',
83
+ svg: '.svg',
84
+ },
85
+ },
86
+ };
87
+
88
+ // All supported export format keys
89
+ const ALL_FORMAT_KEYS = [
90
+ ...new Set(
91
+ Object.values(EXPORT_FORMATS).flatMap((t) => Object.keys(t.formats))
92
+ ),
93
+ ];
94
+
95
+ export function register(server) {
96
+ server.addTool({
97
+ name: 'downloadFile',
98
+ description:
99
+ 'Downloads a file from Google Drive to the local filesystem. ' +
100
+ 'For Google Workspace files (Docs, Sheets, Slides, Drawings), specify an export format ' +
101
+ '(e.g. pdf, docx, md, xlsx, csv, pptx). For regular files (images, PDFs, etc.), ' +
102
+ 'downloads the original file directly.',
103
+ parameters: z.object({
104
+ fileId: z.string().describe('The ID of the file to download.'),
105
+ localPath: z
106
+ .string()
107
+ .describe(
108
+ 'The local file path to save the downloaded file to. ' +
109
+ 'Can be an absolute path or relative to the current working directory. ' +
110
+ 'If a directory is provided, the file will be saved with its Drive name.'
111
+ ),
112
+ exportFormat: z
113
+ .enum(ALL_FORMAT_KEYS)
114
+ .optional()
115
+ .describe(
116
+ 'Export format for Google Workspace files. ' +
117
+ 'Docs: pdf, docx, md, txt, html, rtf, odt, epub. ' +
118
+ 'Sheets: pdf, xlsx, csv, tsv, ods. ' +
119
+ 'Slides: pdf, pptx, odp, txt. ' +
120
+ 'Drawings: pdf, png, jpg, svg. ' +
121
+ 'Ignored for non-Google files (they download as-is).'
122
+ ),
123
+ }),
124
+ execute: async (args, { log }) => {
125
+ const drive = await getDriveClient();
126
+ log.info(`Downloading file: ${args.fileId}`);
127
+
128
+ try {
129
+ // Get file metadata first
130
+ const metaResponse = await drive.files.get({
131
+ fileId: args.fileId,
132
+ fields: 'id,name,mimeType,size',
133
+ supportsAllDrives: true,
134
+ });
135
+ const file = metaResponse.data;
136
+ if (!file) {
137
+ throw new UserError(
138
+ `File with ID ${args.fileId} not found.`
139
+ );
140
+ }
141
+
142
+ const isGoogleWorkspace =
143
+ file.mimeType in EXPORT_FORMATS;
144
+
145
+ // Determine export MIME type and file extension
146
+ let exportMimeType = null;
147
+ let fileExtension = '';
148
+
149
+ if (isGoogleWorkspace) {
150
+ const typeInfo = EXPORT_FORMATS[file.mimeType];
151
+ const format =
152
+ args.exportFormat || typeInfo.defaultFormat;
153
+
154
+ if (!typeInfo.formats[format]) {
155
+ const available = Object.keys(
156
+ typeInfo.formats
157
+ ).join(', ');
158
+ throw new UserError(
159
+ `Format '${format}' is not supported for ${typeInfo.label}. ` +
160
+ `Available formats: ${available}`
161
+ );
162
+ }
163
+
164
+ exportMimeType = typeInfo.formats[format];
165
+ fileExtension = typeInfo.extensions[format];
166
+ }
167
+
168
+ // Resolve the local path
169
+ let destPath = path.resolve(args.localPath);
170
+
171
+ // Check if destPath is a directory
172
+ let isDir = false;
173
+ try {
174
+ const stat = await fs.stat(destPath);
175
+ isDir = stat.isDirectory();
176
+ } catch {
177
+ // Path doesn't exist yet — treat as a file path
178
+ }
179
+
180
+ if (isDir) {
181
+ // Build filename from Drive name + appropriate extension
182
+ let fileName = file.name;
183
+ if (isGoogleWorkspace && fileExtension) {
184
+ // Google Workspace files don't have extensions in their name
185
+ fileName = file.name + fileExtension;
186
+ }
187
+ destPath = path.join(destPath, fileName);
188
+ } else if (
189
+ isGoogleWorkspace &&
190
+ fileExtension &&
191
+ !destPath.endsWith(fileExtension)
192
+ ) {
193
+ // Append extension if not already present
194
+ destPath = destPath + fileExtension;
195
+ }
196
+
197
+ // Ensure parent directory exists
198
+ await fs.mkdir(path.dirname(destPath), { recursive: true });
199
+
200
+ // Download the file
201
+ if (isGoogleWorkspace) {
202
+ log.info(
203
+ `Exporting ${EXPORT_FORMATS[file.mimeType].label} as ${args.exportFormat || EXPORT_FORMATS[file.mimeType].defaultFormat}`
204
+ );
205
+ const exportResponse = await drive.files.export(
206
+ { fileId: args.fileId, mimeType: exportMimeType },
207
+ { responseType: 'stream' }
208
+ );
209
+ await pipeline(
210
+ exportResponse.data,
211
+ createWriteStream(destPath)
212
+ );
213
+ } else {
214
+ log.info(`Downloading binary file: ${file.name}`);
215
+ const downloadResponse = await drive.files.get(
216
+ {
217
+ fileId: args.fileId,
218
+ alt: 'media',
219
+ supportsAllDrives: true,
220
+ },
221
+ { responseType: 'stream' }
222
+ );
223
+ await pipeline(
224
+ downloadResponse.data,
225
+ createWriteStream(destPath)
226
+ );
227
+ }
228
+
229
+ // Get the final file size
230
+ const stat = await fs.stat(destPath);
231
+
232
+ const result = {
233
+ success: true,
234
+ fileName: file.name,
235
+ savedTo: destPath,
236
+ fileSize: stat.size,
237
+ mimeType: isGoogleWorkspace
238
+ ? exportMimeType
239
+ : file.mimeType,
240
+ };
241
+
242
+ if (isGoogleWorkspace) {
243
+ result.exportedAs =
244
+ args.exportFormat ||
245
+ EXPORT_FORMATS[file.mimeType].defaultFormat;
246
+ }
247
+
248
+ return JSON.stringify(result, null, 2);
249
+ } catch (error) {
250
+ if (error instanceof UserError) throw error;
251
+ log.error(
252
+ `Error downloading file: ${error.message || error}`
253
+ );
254
+ if (error.code === 404)
255
+ throw new UserError(
256
+ `File not found (ID: ${args.fileId}).`
257
+ );
258
+ if (error.code === 403)
259
+ throw new UserError(
260
+ 'Permission denied. Make sure you have access to this file.'
261
+ );
262
+ throw new UserError(
263
+ `Failed to download file: ${error.message || 'Unknown error'}`
264
+ );
265
+ }
266
+ },
267
+ });
268
+ }
@@ -12,6 +12,7 @@ import { register as createDocument } from './createDocument.js';
12
12
  import { register as createFromTemplate } from './createFromTemplate.js';
13
13
  import { register as listSharedDrives } from './listSharedDrives.js';
14
14
  import { register as listSharedWithMe } from './listSharedWithMe.js';
15
+ import { register as downloadFile } from './downloadFile.js';
15
16
  export function registerDriveTools(server) {
16
17
  listDriveFiles(server);
17
18
  searchGoogleDocs(server);
@@ -27,4 +28,5 @@ export function registerDriveTools(server) {
27
28
  createFromTemplate(server);
28
29
  listSharedDrives(server);
29
30
  listSharedWithMe(server);
31
+ downloadFile(server);
30
32
  }
@@ -56,8 +56,9 @@ export function register(server) {
56
56
  // Filter by mime type
57
57
  if (args.fileType !== 'all') {
58
58
  queryParts.push(`mimeType='${MIME_TYPES[args.fileType]}'`);
59
- } else {
60
- // Exclude folders from "all" results
59
+ } else if (!args.query) {
60
+ // Exclude folders from "all" results only when not doing fullText search
61
+ // (Drive API doesn't allow fullText contains with negated mimeType filters)
61
62
  queryParts.push("mimeType!='application/vnd.google-apps.folder'");
62
63
  }
63
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Combined Google Workspace MCP server (Drive, Docs, Sheets, Gmail) with lazy-loaded tool categories",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,49 +0,0 @@
1
- import { UserError } from 'fastmcp';
2
- import { getDocsClient } from '../../../clients.js';
3
- import { ApplyTextStyleToolParameters, NotImplementedError, } from '../../../types.js';
4
- import * as GDocsHelpers from '../../../googleDocsApiHelpers.js';
5
- export function register(server) {
6
- server.addTool({
7
- name: 'applyTextStyle',
8
- description: 'Applies character-level formatting (bold, italic, color, font, etc.) to text identified by a character range or by searching for a text string. This is the primary tool for styling text in a document.',
9
- parameters: ApplyTextStyleToolParameters,
10
- execute: async (args, { log }) => {
11
- const docs = await getDocsClient();
12
- let { startIndex, endIndex } = args.target; // Will be updated if target is text
13
- log.info(`Applying text style in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`);
14
- try {
15
- // Determine target range
16
- if ('textToFind' in args.target) {
17
- const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance, args.tabId);
18
- if (!range) {
19
- throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`);
20
- }
21
- startIndex = range.startIndex;
22
- endIndex = range.endIndex;
23
- log.info(`Found text "${args.target.textToFind}" (instance ${args.target.matchInstance}) at range ${startIndex}-${endIndex}`);
24
- }
25
- if (startIndex === undefined || endIndex === undefined) {
26
- throw new UserError('Target range could not be determined.');
27
- }
28
- if (endIndex <= startIndex) {
29
- throw new UserError('End index must be greater than start index for styling.');
30
- }
31
- // Build the request
32
- const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(startIndex, endIndex, args.style, args.tabId);
33
- if (!requestInfo) {
34
- return 'No valid text styling options were provided.';
35
- }
36
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
37
- return `Successfully applied text style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
38
- }
39
- catch (error) {
40
- log.error(`Error applying text style in doc ${args.documentId}: ${error.message || error}`);
41
- if (error instanceof UserError)
42
- throw error;
43
- if (error instanceof NotImplementedError)
44
- throw error; // Should not happen here
45
- throw new UserError(`Failed to apply text style: ${error.message || 'Unknown error'}`);
46
- }
47
- },
48
- });
49
- }
@@ -1,61 +0,0 @@
1
- import { UserError } from 'fastmcp';
2
- import { z } from 'zod';
3
- import { getDocsClient } from '../../clients.js';
4
- import { DocumentIdParameter } from '../../types.js';
5
- import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
- export function register(server) {
7
- server.addTool({
8
- name: 'insertText',
9
- description: "Inserts text at a specific character index within a document. Use readDocument with format='json' to determine the correct index.",
10
- parameters: DocumentIdParameter.extend({
11
- text: z.string().min(1).describe('The text to insert.'),
12
- index: z
13
- .number()
14
- .int()
15
- .min(1)
16
- .describe("1-based character index within the document body. Use readDocument with format='json' to inspect indices."),
17
- tabId: z
18
- .string()
19
- .optional()
20
- .describe('The ID of the specific tab to insert into. If not specified, inserts into the first tab (or legacy document.body for documents without tabs).'),
21
- }),
22
- execute: async (args, { log }) => {
23
- const docs = await getDocsClient();
24
- log.info(`Inserting text in doc ${args.documentId} at index ${args.index}${args.tabId ? ` (tab: ${args.tabId})` : ''}`);
25
- try {
26
- if (args.tabId) {
27
- // For tab-specific inserts, we need to verify the tab exists first
28
- const docInfo = await docs.documents.get({
29
- documentId: args.documentId,
30
- includeTabsContent: true,
31
- fields: 'tabs(tabProperties,documentTab)',
32
- });
33
- const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId);
34
- if (!targetTab) {
35
- throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
36
- }
37
- if (!targetTab.documentTab) {
38
- throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`);
39
- }
40
- // Insert with tabId
41
- const location = { index: args.index, tabId: args.tabId };
42
- const request = {
43
- insertText: { location, text: args.text },
44
- };
45
- await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
46
- }
47
- else {
48
- // Use existing helper for backward compatibility
49
- await GDocsHelpers.insertText(docs, args.documentId, args.text, args.index);
50
- }
51
- return `Successfully inserted text at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
52
- }
53
- catch (error) {
54
- log.error(`Error inserting text in doc ${args.documentId}: ${error.message || error}`);
55
- if (error instanceof UserError)
56
- throw error;
57
- throw new UserError(`Failed to insert text: ${error.message || 'Unknown error'}`);
58
- }
59
- },
60
- });
61
- }
@@ -1,48 +0,0 @@
1
- import { UserError } from 'fastmcp';
2
- import { getDriveClient } from '../../clients.js';
3
- import { DocumentIdParameter } from '../../types.js';
4
- export function register(server) {
5
- server.addTool({
6
- name: 'getDocumentInfo',
7
- description: 'Gets metadata about a document including its name, owner, sharing status, and modification history.',
8
- parameters: DocumentIdParameter,
9
- execute: async (args, { log }) => {
10
- const drive = await getDriveClient();
11
- log.info(`Getting info for document: ${args.documentId}`);
12
- try {
13
- const response = await drive.files.get({
14
- fileId: args.documentId,
15
- // Note: 'permissions' and 'alternateLink' fields removed - they cause
16
- // "Invalid field selection" errors for Google Docs files
17
- fields: 'id,name,description,mimeType,size,createdTime,modifiedTime,webViewLink,owners(displayName,emailAddress),lastModifyingUser(displayName,emailAddress),shared,parents,version',
18
- supportsAllDrives: true,
19
- });
20
- const file = response.data;
21
- if (!file) {
22
- throw new UserError(`Document with ID ${args.documentId} not found.`);
23
- }
24
- const info = {
25
- id: file.id,
26
- name: file.name,
27
- mimeType: file.mimeType,
28
- createdTime: file.createdTime,
29
- modifiedTime: file.modifiedTime,
30
- owner: file.owners?.[0]?.displayName || null,
31
- lastModifyingUser: file.lastModifyingUser?.displayName || null,
32
- shared: file.shared || false,
33
- url: file.webViewLink,
34
- description: file.description || null,
35
- };
36
- return JSON.stringify(info, null, 2);
37
- }
38
- catch (error) {
39
- log.error(`Error getting document info: ${error.message || error}`);
40
- if (error.code === 404)
41
- throw new UserError(`Document not found (ID: ${args.documentId}).`);
42
- if (error.code === 403)
43
- throw new UserError('Permission denied. Make sure you have access to this document.');
44
- throw new UserError(`Failed to get document info: ${error.message || 'Unknown error'}`);
45
- }
46
- },
47
- });
48
- }
@@ -1,67 +0,0 @@
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: 'listDocuments',
7
- description: 'Lists Google Documents in your Drive, optionally filtered by name or content. Use modifiedAfter to find recently changed documents.',
8
- parameters: z.object({
9
- maxResults: z
10
- .number()
11
- .int()
12
- .min(1)
13
- .max(100)
14
- .optional()
15
- .default(20)
16
- .describe('Maximum number of documents to return (1-100).'),
17
- query: z.string().optional().describe('Search query to filter documents by name or content.'),
18
- orderBy: z
19
- .enum(['name', 'modifiedTime', 'createdTime'])
20
- .optional()
21
- .default('modifiedTime')
22
- .describe('Sort order for results.'),
23
- modifiedAfter: z
24
- .string()
25
- .optional()
26
- .describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'),
27
- }),
28
- execute: async (args, { log }) => {
29
- const drive = await getDriveClient();
30
- log.info(`Listing Google Docs. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`);
31
- try {
32
- // Build the query string for Google Drive API
33
- let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
34
- if (args.query) {
35
- queryString += ` and (name contains '${args.query}' or fullText contains '${args.query}')`;
36
- }
37
- if (args.modifiedAfter) {
38
- const cutoffDate = new Date(args.modifiedAfter).toISOString();
39
- queryString += ` and modifiedTime > '${cutoffDate}'`;
40
- }
41
- const response = await drive.files.list({
42
- q: queryString,
43
- pageSize: args.maxResults,
44
- orderBy: args.orderBy === 'name' ? 'name' : args.orderBy,
45
- fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
46
- supportsAllDrives: true,
47
- includeItemsFromAllDrives: true,
48
- });
49
- const files = response.data.files || [];
50
- const documents = files.map((file) => ({
51
- id: file.id,
52
- name: file.name,
53
- modifiedTime: file.modifiedTime,
54
- owner: file.owners?.[0]?.displayName || null,
55
- url: file.webViewLink,
56
- }));
57
- return JSON.stringify({ documents }, null, 2);
58
- }
59
- catch (error) {
60
- log.error(`Error listing Google Docs: ${error.message || error}`);
61
- if (error.code === 403)
62
- throw new UserError('Permission denied. Make sure you have granted Google Drive access to the application.');
63
- throw new UserError(`Failed to list documents: ${error.message || 'Unknown error'}`);
64
- }
65
- },
66
- });
67
- }
@@ -1,62 +0,0 @@
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: 'listSpreadsheets',
7
- description: 'Lists spreadsheets in your Drive, optionally filtered by name or content.',
8
- parameters: z.object({
9
- maxResults: z
10
- .number()
11
- .int()
12
- .min(1)
13
- .max(100)
14
- .optional()
15
- .default(20)
16
- .describe('Maximum number of spreadsheets to return (1-100).'),
17
- query: z
18
- .string()
19
- .optional()
20
- .describe('Search query to filter spreadsheets by name or content.'),
21
- orderBy: z
22
- .enum(['name', 'modifiedTime', 'createdTime'])
23
- .optional()
24
- .default('modifiedTime')
25
- .describe('Sort order for results.'),
26
- }),
27
- execute: async (args, { log }) => {
28
- const drive = await getDriveClient();
29
- log.info(`Listing Google Sheets. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`);
30
- try {
31
- // Build the query string for Google Drive API
32
- let queryString = "mimeType='application/vnd.google-apps.spreadsheet' and trashed=false";
33
- if (args.query) {
34
- queryString += ` and (name contains '${args.query}' or fullText contains '${args.query}')`;
35
- }
36
- const response = await drive.files.list({
37
- q: queryString,
38
- pageSize: args.maxResults,
39
- orderBy: args.orderBy === 'name' ? 'name' : args.orderBy,
40
- fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
41
- supportsAllDrives: true,
42
- includeItemsFromAllDrives: true,
43
- });
44
- const files = response.data.files || [];
45
- const spreadsheets = files.map((file) => ({
46
- id: file.id,
47
- name: file.name,
48
- modifiedTime: file.modifiedTime,
49
- owner: file.owners?.[0]?.displayName || null,
50
- url: file.webViewLink,
51
- }));
52
- return JSON.stringify({ spreadsheets }, null, 2);
53
- }
54
- catch (error) {
55
- log.error(`Error listing Google Sheets: ${error.message || error}`);
56
- if (error.code === 403)
57
- throw new UserError('Permission denied. Make sure you have granted Google Drive access to the application.');
58
- throw new UserError(`Failed to list spreadsheets: ${error.message || 'Unknown error'}`);
59
- }
60
- },
61
- });
62
- }