mcp-google-gdrive 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/SPEC.md +917 -0
  4. package/package.json +35 -0
  5. package/src/index.js +487 -0
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "mcp-google-gdrive",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Google Drive API with full file management and Workspace format conversion",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-google-gdrive": "./src/index.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "scripts": {
11
+ "start": "node src/index.js",
12
+ "auth": "node src/index.js --auth"
13
+ },
14
+ "keywords": [
15
+ "mcp",
16
+ "google-drive",
17
+ "google-workspace",
18
+ "file-management"
19
+ ],
20
+ "license": "MIT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/sleepytimeshon/mcp-google-gdrive.git"
24
+ },
25
+ "homepage": "https://github.com/sleepytimeshon/mcp-google-gdrive#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/sleepytimeshon/mcp-google-gdrive/issues"
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.26.0",
31
+ "googleapis": "^144.0.0",
32
+ "turndown": "^7.2.0",
33
+ "zod": "^3.25.0"
34
+ }
35
+ }
package/src/index.js ADDED
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { z } from "zod";
6
+ import { google } from "googleapis";
7
+ import TurndownService from "turndown";
8
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import { createInterface } from "node:readline";
12
+
13
+ // ─── Constants ───────────────────────────────────────────────────────────────
14
+
15
+ const SCOPES = ["https://www.googleapis.com/auth/drive"];
16
+ const CONFIG_DIR = join(homedir(), ".config", "mcp-google-gdrive");
17
+ const CREDENTIALS_PATH = join(CONFIG_DIR, "credentials.json");
18
+ const TOKEN_PATH = join(CONFIG_DIR, "token.json");
19
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
20
+
21
+ const DRIVE_ID_REGEX = /^[a-zA-Z0-9_-]+$/;
22
+
23
+ const turndown = new TurndownService({
24
+ headingStyle: "atx",
25
+ codeBlockStyle: "fenced",
26
+ bulletListMarker: "-",
27
+ emDelimiter: "_",
28
+ strongDelimiter: "**",
29
+ });
30
+
31
+ // ─── Auth ────────────────────────────────────────────────────────────────────
32
+
33
+ let authClient = null;
34
+ let driveClient = null;
35
+
36
+ async function ensureConfigDir() {
37
+ await mkdir(CONFIG_DIR, { recursive: true });
38
+ }
39
+
40
+ async function loadCredentials() {
41
+ try {
42
+ const content = await readFile(CREDENTIALS_PATH, "utf-8");
43
+ return JSON.parse(content);
44
+ } catch (err) {
45
+ if (err.code === "ENOENT") {
46
+ throw new Error(
47
+ `credentials.json not found at ${CREDENTIALS_PATH}\n` +
48
+ "Setup instructions:\n" +
49
+ "1. Go to https://console.cloud.google.com/\n" +
50
+ "2. Create a project and enable the Google Drive API\n" +
51
+ "3. Create OAuth2 desktop credentials\n" +
52
+ "4. Download the JSON and save it to:\n" +
53
+ ` ${CREDENTIALS_PATH}`
54
+ );
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ async function loadToken() {
61
+ try {
62
+ const content = await readFile(TOKEN_PATH, "utf-8");
63
+ return JSON.parse(content);
64
+ } catch (err) {
65
+ if (err.code === "ENOENT") return null;
66
+ throw err;
67
+ }
68
+ }
69
+
70
+ async function saveToken(token) {
71
+ await writeFile(TOKEN_PATH, JSON.stringify(token, null, 2));
72
+ await chmod(TOKEN_PATH, 0o600);
73
+ }
74
+
75
+ async function authorize() {
76
+ await ensureConfigDir();
77
+ const credentials = await loadCredentials();
78
+
79
+ const { client_id, client_secret, redirect_uris } =
80
+ credentials.installed || credentials.web;
81
+
82
+ const oauth2Client = new google.auth.OAuth2(
83
+ client_id,
84
+ client_secret,
85
+ redirect_uris?.[0] || "urn:ietf:wg:oauth:2.0:oob"
86
+ );
87
+
88
+ // Try loading existing token
89
+ const token = await loadToken();
90
+ if (!token) {
91
+ throw new Error(
92
+ `No token.json found. Run first-time authorization:\n` +
93
+ ` node src/index.js --auth\n\n` +
94
+ `This must be done before starting the MCP server.`
95
+ );
96
+ }
97
+
98
+ oauth2Client.setCredentials(token);
99
+
100
+ // Auto-save on token refresh
101
+ oauth2Client.on("tokens", async (newTokens) => {
102
+ try {
103
+ const existing = await loadToken();
104
+ const merged = { ...existing, ...newTokens };
105
+ await saveToken(merged);
106
+ } catch (err) {
107
+ process.stderr.write(`Warning: Failed to save refreshed token: ${err.message}\n`);
108
+ }
109
+ });
110
+
111
+ return oauth2Client;
112
+ }
113
+
114
+ async function interactiveAuth() {
115
+ await ensureConfigDir();
116
+ const credentials = await loadCredentials();
117
+
118
+ const { client_id, client_secret, redirect_uris } =
119
+ credentials.installed || credentials.web;
120
+
121
+ const oauth2Client = new google.auth.OAuth2(
122
+ client_id,
123
+ client_secret,
124
+ redirect_uris?.[0] || "urn:ietf:wg:oauth:2.0:oob"
125
+ );
126
+
127
+ const authUrl = oauth2Client.generateAuthUrl({
128
+ access_type: "offline",
129
+ scope: SCOPES,
130
+ prompt: "consent",
131
+ });
132
+
133
+ process.stderr.write("\n═══ Google Drive MCP — First-Run Authorization ═══\n\n");
134
+ process.stderr.write("Open this URL in your browser:\n\n");
135
+ process.stderr.write(` ${authUrl}\n\n`);
136
+ process.stderr.write("After granting access, paste the authorization code below.\n\n");
137
+
138
+ const rl = createInterface({
139
+ input: process.stdin,
140
+ output: process.stderr,
141
+ });
142
+
143
+ const code = await new Promise((resolve) => {
144
+ rl.question("Authorization code: ", (answer) => {
145
+ rl.close();
146
+ resolve(answer.trim());
147
+ });
148
+ });
149
+
150
+ const { tokens } = await oauth2Client.getToken(code);
151
+ await saveToken(tokens);
152
+
153
+ process.stderr.write("\nAuthorization successful! Token saved.\n");
154
+ process.stderr.write(`Token stored at: ${TOKEN_PATH}\n\n`);
155
+ }
156
+
157
+ function getDrive() {
158
+ if (!driveClient) throw new Error("Drive client not initialized. Auth may have failed.");
159
+ return driveClient;
160
+ }
161
+
162
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
163
+
164
+ function jsonResponse(data) {
165
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
166
+ }
167
+
168
+ function textResponse(text) {
169
+ return { content: [{ type: "text", text }] };
170
+ }
171
+
172
+ function errorResponse(message) {
173
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
174
+ }
175
+
176
+ const FILE_FIELDS = "id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,owners,shared,description";
177
+ const LIST_FIELDS = "id,name,mimeType,modifiedTime,size,parents";
178
+
179
+ // ─── Server ──────────────────────────────────────────────────────────────────
180
+
181
+ const server = new McpServer({
182
+ name: "mcp-google-gdrive",
183
+ version: "0.1.0",
184
+ });
185
+
186
+ // ─── Tool: gdrive_list_files ─────────────────────────────────────────────────
187
+
188
+ server.registerTool(
189
+ "gdrive_list_files",
190
+ {
191
+ description:
192
+ "List files in Google Drive with optional search query and folder filtering. " +
193
+ "Use the query parameter for Drive search syntax (e.g., \"name contains 'report'\", " +
194
+ "\"mimeType = 'application/pdf'\"). Use folderId to list contents of a specific folder.",
195
+ inputSchema: {
196
+ query: z.string().optional().describe("Drive search query (e.g., \"name contains 'report'\")"),
197
+ folderId: z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid folder ID format").optional().describe("List files in this folder only"),
198
+ pageSize: z.number().optional().default(20).describe("Results per page (max 100)"),
199
+ pageToken: z.string().optional().describe("Token for next page of results"),
200
+ orderBy: z.string().optional().default("modifiedTime desc").describe("Sort order"),
201
+ },
202
+ annotations: {
203
+ readOnlyHint: true,
204
+ destructiveHint: false,
205
+ idempotentHint: true,
206
+ openWorldHint: true,
207
+ },
208
+ },
209
+ async ({ query, folderId, pageSize, pageToken, orderBy }) => {
210
+ try {
211
+ const drive = await getDrive();
212
+
213
+ let q = "trashed = false";
214
+ if (folderId) q += ` and '${folderId}' in parents`;
215
+ if (query) q += ` and ${query}`;
216
+
217
+ const res = await drive.files.list({
218
+ q,
219
+ pageSize: Math.min(pageSize || 20, 100),
220
+ pageToken: pageToken || undefined,
221
+ orderBy: orderBy || "modifiedTime desc",
222
+ fields: `nextPageToken,files(${LIST_FIELDS})`,
223
+ });
224
+
225
+ return jsonResponse({
226
+ files: res.data.files || [],
227
+ nextPageToken: res.data.nextPageToken || null,
228
+ resultCount: (res.data.files || []).length,
229
+ });
230
+ } catch (err) {
231
+ return errorResponse(err.message);
232
+ }
233
+ }
234
+ );
235
+
236
+ // ─── Tool: gdrive_get_metadata ───────────────────────────────────────────────
237
+
238
+ server.registerTool(
239
+ "gdrive_get_metadata",
240
+ {
241
+ description:
242
+ "Get detailed metadata for a specific file in Google Drive by its file ID. " +
243
+ "Returns name, type, size, dates, parents, web link, and sharing status.",
244
+ inputSchema: {
245
+ fileId: z.string().describe("Google Drive file ID"),
246
+ },
247
+ annotations: {
248
+ readOnlyHint: true,
249
+ destructiveHint: false,
250
+ idempotentHint: true,
251
+ openWorldHint: true,
252
+ },
253
+ },
254
+ async ({ fileId }) => {
255
+ try {
256
+ const drive = await getDrive();
257
+ const res = await drive.files.get({
258
+ fileId,
259
+ fields: FILE_FIELDS,
260
+ });
261
+ return jsonResponse(res.data);
262
+ } catch (err) {
263
+ if (err.code === 404) {
264
+ return errorResponse(`File not found: ${fileId}. Verify the file ID exists and you have access.`);
265
+ }
266
+ return errorResponse(err.message);
267
+ }
268
+ }
269
+ );
270
+
271
+ // ─── Tool: gdrive_read_file ─────────────────────────────────────────────────
272
+
273
+ server.registerTool(
274
+ "gdrive_read_file",
275
+ {
276
+ description:
277
+ "Read the content of a file from Google Drive. Automatically detects Google Workspace types: " +
278
+ "Docs are exported as Markdown, Sheets as CSV, Slides as plain text. " +
279
+ "Binary files under 10MB are returned as base64. Files over 10MB return metadata only.",
280
+ inputSchema: {
281
+ fileId: z.string().describe("Google Drive file ID"),
282
+ },
283
+ annotations: {
284
+ readOnlyHint: true,
285
+ destructiveHint: false,
286
+ idempotentHint: true,
287
+ openWorldHint: true,
288
+ },
289
+ },
290
+ async ({ fileId }) => {
291
+ try {
292
+ const drive = await getDrive();
293
+
294
+ // Get file metadata first
295
+ const meta = await drive.files.get({
296
+ fileId,
297
+ fields: "id,name,mimeType,size,webViewLink",
298
+ });
299
+
300
+ const { mimeType, name, size } = meta.data;
301
+
302
+ // Google Workspace types — export
303
+ if (mimeType === "application/vnd.google-apps.document") {
304
+ const res = await drive.files.export({
305
+ fileId,
306
+ mimeType: "text/html",
307
+ }, { responseType: "text" });
308
+ const markdown = turndown.turndown(res.data);
309
+ return textResponse(markdown);
310
+ }
311
+
312
+ if (mimeType === "application/vnd.google-apps.spreadsheet") {
313
+ const res = await drive.files.export({
314
+ fileId,
315
+ mimeType: "text/csv",
316
+ }, { responseType: "text" });
317
+ return textResponse(res.data);
318
+ }
319
+
320
+ if (mimeType === "application/vnd.google-apps.presentation") {
321
+ const res = await drive.files.export({
322
+ fileId,
323
+ mimeType: "text/plain",
324
+ }, { responseType: "text" });
325
+ return textResponse(res.data);
326
+ }
327
+
328
+ if (mimeType === "application/vnd.google-apps.drawing") {
329
+ return textResponse(`[Google Drawing: ${name}] — Export not supported in read_file. Use export tools.`);
330
+ }
331
+
332
+ if (mimeType && mimeType.startsWith("application/vnd.google-apps.")) {
333
+ return errorResponse(`Cannot read Google Workspace type: ${mimeType}. File: ${name}`);
334
+ }
335
+
336
+ // Regular files — check size
337
+ const fileSize = parseInt(size, 10) || 0;
338
+ if (fileSize > MAX_FILE_SIZE) {
339
+ return jsonResponse({
340
+ warning: "File exceeds 10MB limit. Returning metadata only.",
341
+ name,
342
+ mimeType,
343
+ size: fileSize,
344
+ sizeFormatted: `${(fileSize / (1024 * 1024)).toFixed(1)} MB`,
345
+ webViewLink: meta.data.webViewLink,
346
+ });
347
+ }
348
+
349
+ // Download regular file
350
+ const res = await drive.files.get(
351
+ { fileId, alt: "media" },
352
+ { responseType: mimeType?.startsWith("text/") ? "text" : "arraybuffer" }
353
+ );
354
+
355
+ if (mimeType?.startsWith("text/")) {
356
+ return textResponse(res.data);
357
+ }
358
+
359
+ // Binary — return base64
360
+ const buffer = Buffer.from(res.data);
361
+ return textResponse(`[Binary file: ${name} (${mimeType}, ${buffer.length} bytes)]\n\nBase64:\n${buffer.toString("base64")}`);
362
+ } catch (err) {
363
+ if (err.code === 404) {
364
+ return errorResponse(`File not found: ${fileId}. Verify the file ID exists and you have access.`);
365
+ }
366
+ return errorResponse(err.message);
367
+ }
368
+ }
369
+ );
370
+
371
+ // ─── Tool: gdrive_create_file ────────────────────────────────────────────────
372
+
373
+ server.registerTool(
374
+ "gdrive_create_file",
375
+ {
376
+ description:
377
+ "Create a new file in Google Drive with the specified name and content. " +
378
+ "Optionally specify a parent folder and MIME type.",
379
+ inputSchema: {
380
+ name: z.string().describe("File name including extension (e.g., 'notes.txt')"),
381
+ content: z.string().describe("File content (text)"),
382
+ mimeType: z.string().optional().default("text/plain").describe("MIME type of content"),
383
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
384
+ },
385
+ annotations: {
386
+ readOnlyHint: false,
387
+ destructiveHint: false,
388
+ idempotentHint: false,
389
+ openWorldHint: true,
390
+ },
391
+ },
392
+ async ({ name, content, mimeType, parentFolderId }) => {
393
+ try {
394
+ const drive = await getDrive();
395
+
396
+ const fileMetadata = { name };
397
+ if (parentFolderId) fileMetadata.parents = [parentFolderId];
398
+
399
+ const media = {
400
+ mimeType: mimeType || "text/plain",
401
+ body: content,
402
+ };
403
+
404
+ const res = await drive.files.create({
405
+ requestBody: fileMetadata,
406
+ media,
407
+ fields: "id,name,mimeType,webViewLink",
408
+ });
409
+
410
+ return jsonResponse(res.data);
411
+ } catch (err) {
412
+ if (err.code === 404) {
413
+ return errorResponse(`Parent folder not found: ${parentFolderId}`);
414
+ }
415
+ return errorResponse(err.message);
416
+ }
417
+ }
418
+ );
419
+
420
+ // ─── Tool: gdrive_create_folder ──────────────────────────────────────────────
421
+
422
+ server.registerTool(
423
+ "gdrive_create_folder",
424
+ {
425
+ description:
426
+ "Create a new folder in Google Drive. Optionally specify a parent folder.",
427
+ inputSchema: {
428
+ name: z.string().describe("Folder name"),
429
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
430
+ },
431
+ annotations: {
432
+ readOnlyHint: false,
433
+ destructiveHint: false,
434
+ idempotentHint: false,
435
+ openWorldHint: true,
436
+ },
437
+ },
438
+ async ({ name, parentFolderId }) => {
439
+ try {
440
+ const drive = await getDrive();
441
+
442
+ const fileMetadata = {
443
+ name,
444
+ mimeType: "application/vnd.google-apps.folder",
445
+ };
446
+ if (parentFolderId) fileMetadata.parents = [parentFolderId];
447
+
448
+ const res = await drive.files.create({
449
+ requestBody: fileMetadata,
450
+ fields: "id,name,mimeType,webViewLink",
451
+ });
452
+
453
+ return jsonResponse(res.data);
454
+ } catch (err) {
455
+ if (err.code === 404) {
456
+ return errorResponse(`Parent folder not found: ${parentFolderId}`);
457
+ }
458
+ return errorResponse(err.message);
459
+ }
460
+ }
461
+ );
462
+
463
+ // ─── Start ───────────────────────────────────────────────────────────────────
464
+
465
+ async function main() {
466
+ // Handle --auth flag for interactive first-run authorization
467
+ if (process.argv.includes("--auth")) {
468
+ await interactiveAuth();
469
+ process.exit(0);
470
+ }
471
+
472
+ try {
473
+ authClient = await authorize();
474
+ driveClient = google.drive({ version: "v3", auth: authClient });
475
+ } catch (err) {
476
+ process.stderr.write(`\nAuth error: ${err.message}\n`);
477
+ process.exit(1);
478
+ }
479
+
480
+ const transport = new StdioServerTransport();
481
+ await server.connect(transport);
482
+ }
483
+
484
+ main().catch((err) => {
485
+ process.stderr.write(`\nFatal: ${err.message}\n`);
486
+ process.exit(1);
487
+ });