mcp-google-gdrive 2.0.0 → 2.2.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.
package/README.md CHANGED
@@ -19,6 +19,7 @@ An [MCP](https://modelcontextprotocol.io/) server for Google Drive, Docs, Sheets
19
19
  | `gdrive_list_files` | List files with optional search query and folder filtering |
20
20
  | `gdrive_get_metadata` | Get detailed metadata for a file by ID |
21
21
  | `gdrive_read_file` | Read file content with automatic Workspace format conversion |
22
+ | `gdrive_download_file` | Download a file to local disk (supports Workspace export) |
22
23
  | `gdrive_create_file` | Create a new file with optional OCR on image/PDF uploads |
23
24
  | `gdrive_update_file` | Update a file's content, name, or description |
24
25
  | `gdrive_copy_file` | Copy a file, optionally to a different folder |
@@ -64,7 +65,7 @@ An [MCP](https://modelcontextprotocol.io/) server for Google Drive, Docs, Sheets
64
65
 
65
66
  | Tool | Description |
66
67
  |------|-------------|
67
- | `gdrive_export_doc` | Export a Google Doc (markdown, html, text, docx, pdf) |
68
+ | `gdrive_export_doc` | Export a Google Doc (markdown, html, text, docx, pdf). Use `savePath` to save binary formats to disk |
68
69
  | `gdrive_create_doc` | Create a Google Doc from Markdown content |
69
70
  | `gdrive_update_doc` | Replace a Google Doc's content with Markdown |
70
71
 
@@ -73,7 +74,7 @@ An [MCP](https://modelcontextprotocol.io/) server for Google Drive, Docs, Sheets
73
74
  | Tool | Description |
74
75
  |------|-------------|
75
76
  | `gdrive_create_sheet` | Create a new spreadsheet with named tabs |
76
- | `gdrive_export_sheet` | Export a sheet to CSV, JSON, or XLSX |
77
+ | `gdrive_export_sheet` | Export a sheet to CSV, JSON, or XLSX. Use `savePath` to save XLSX to disk |
77
78
  | `gdrive_list_sheets` | List all sheets/tabs in a spreadsheet |
78
79
  | `gdrive_read_sheet_range` | Read a specific range from any tab (A1 notation) |
79
80
  | `gdrive_write_sheet_range` | Write data to a specific range |
@@ -83,9 +84,41 @@ An [MCP](https://modelcontextprotocol.io/) server for Google Drive, Docs, Sheets
83
84
  | Tool | Description |
84
85
  |------|-------------|
85
86
  | `gdrive_create_slides` | Create a new presentation |
86
- | `gdrive_export_slides` | Export slides to text, PDF, or PPTX |
87
+ | `gdrive_export_slides` | Export slides to text, PDF, or PPTX. Use `savePath` to save binary formats to disk |
87
88
  | `gdrive_get_slide_thumbnail` | Get a thumbnail image URL for a specific slide |
88
89
 
90
+ ## What's New in v2.2
91
+
92
+ - **`outputFormat` parameter** — 12 read/list tools now accept `outputFormat: "json" | "yaml" | "text"`. Default is `json` (backwards compatible). Use `yaml` for structured output or `text` for compact human-readable tables. Supported on: `list_accounts`, `list_files`, `list_folder`, `list_trash`, `list_permissions`, `list_labels`, `list_file_labels`, `list_sheets`, `get_metadata`, `read_sheet_range`, `get_slide_thumbnail`, `search_files`.
93
+
94
+ ### v2.1
95
+
96
+ - **`gdrive_download_file`** — Download any file from Drive to a local disk path. Workspace files (Docs, Sheets, Slides) are automatically exported to the specified format (pdf, docx, xlsx, pptx, csv, txt, html, or markdown).
97
+ - **`savePath` on export tools** — `gdrive_export_doc`, `gdrive_export_sheet`, and `gdrive_export_slides` now accept an optional `savePath` parameter. When provided, binary exports (docx, pdf, xlsx, pptx) are saved to disk instead of returned as base64. Omitting `savePath` preserves the v2.0 behavior.
98
+ - **`gdrive_list_accounts` email resolution** — Accounts migrated from v0.x that showed "migrated" instead of an email address now auto-resolve via the Google userinfo API on first call.
99
+ - **Documentation fixes** — `gdrive_empty_trash` notes eventual consistency; `gdrive_transfer_ownership` notes cross-org limitation.
100
+
101
+ ## Output Formats
102
+
103
+ All read and list tools support an `outputFormat` parameter:
104
+
105
+ | Format | Description | Use Case |
106
+ |--------|-------------|----------|
107
+ | `json` | Pretty-printed JSON (default) | Machine consumption, API pipelines |
108
+ | `yaml` | YAML serialization | Human-readable structured data, config files |
109
+ | `text` | Compact aligned tables and key-value pairs | Quick scanning, terminal output, token-efficient LLM context |
110
+
111
+ Example: `gdrive_list_files({ query: "name contains 'report'", outputFormat: "text" })` returns:
112
+
113
+ ```
114
+ files:
115
+ id name mimeType modifiedTime size
116
+ ------------- ---------------- --------------- ------------ ----
117
+ abc123def456 Q1 Report.pdf application/pdf 2026-04-01 1024
118
+ ghi789jkl012 Q2 Report.docx application/pdf 2026-03-15 2048
119
+ resultCount: 2
120
+ ```
121
+
89
122
  ## Prerequisites
90
123
 
91
124
  - Node.js 18+
package/SPEC.md CHANGED
@@ -9,17 +9,19 @@ MCP server providing Claude Code with full read/write access to a personal Googl
9
9
  | Item | Value |
10
10
  |------|-------|
11
11
  | Package name | mcp-google-gdrive |
12
- | Version | 0.1.0 |
13
- | Runtime | Node.js 18+ (ESM), verified 22.22.0 in WSL |
12
+ | Version | 2.2.0 |
13
+ | Runtime | Node.js 18+ (ESM) |
14
14
  | MCP SDK | @modelcontextprotocol/sdk ^1.26.0 |
15
15
  | Validation | zod ^3.25.0 |
16
- | Auth | Google OAuth2 (desktop app flow) |
17
- | API | Google Drive API v3 |
18
- | Extra deps | googleapis ^144.0.0, turndown ^7.2.0, marked ^15.0.0 |
19
- | Location | WSL ~/projects/mcp-servers/mcp-google-gdrive/ |
16
+ | Auth | Google OAuth2 (desktop app flow), multi-account |
17
+ | API | Google Drive API v3, Docs v1, Sheets v4, Slides v1, Labels v2 |
18
+ | Deps | googleapis ^144.0.0, turndown ^7.2.0, js-yaml ^4.1.1 |
19
+ | Source | github.com/sleepytimeshon/mcp-google-gdrive |
20
+ | npm | npmjs.com/package/mcp-google-gdrive |
20
21
  | Entry point | src/index.js |
21
22
  | Transport | stdio (JSON-RPC) |
22
23
  | License | MIT |
24
+ | Tools | 38 across 9 categories |
23
25
 
24
26
  ---
25
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-gdrive",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for Google Drive, Docs, Sheets, and Slides with multi-account support, permissions, labels, and full CRUD",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/sdk": "^1.26.0",
35
35
  "googleapis": "^144.0.0",
36
+ "js-yaml": "^4.1.1",
36
37
  "turndown": "^7.2.0",
37
38
  "zod": "^3.25.0"
38
39
  }
package/src/index.js CHANGED
@@ -5,8 +5,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { z } from "zod";
6
6
  import { google } from "googleapis";
7
7
  import TurndownService from "turndown";
8
+ import yaml from "js-yaml";
8
9
  import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
9
- import { join } from "node:path";
10
+ import { join, resolve, dirname } from "node:path";
10
11
  import { homedir } from "node:os";
11
12
  import { existsSync } from "node:fs";
12
13
  import { createInterface } from "node:readline";
@@ -228,6 +229,30 @@ function sanitizeDriveId(id) {
228
229
  return id;
229
230
  }
230
231
 
232
+ function validateSavePath(savePath) {
233
+ const resolved = resolve(savePath);
234
+ if (resolved.includes("\0")) {
235
+ throw new Error("Invalid save path: null bytes not allowed");
236
+ }
237
+ return resolved;
238
+ }
239
+
240
+ async function saveToDisk(data, savePath) {
241
+ const resolved = validateSavePath(savePath);
242
+ await mkdir(dirname(resolved), { recursive: true });
243
+ const buf = Buffer.from(data);
244
+ await writeFile(resolved, buf);
245
+ return { path: resolved, size: buf.length };
246
+ }
247
+
248
+ async function handleBinaryExport(data, format, savePath) {
249
+ if (savePath) {
250
+ const result = await saveToDisk(data, savePath);
251
+ return jsonResponse({ exported: format.toUpperCase(), path: result.path, size: result.size });
252
+ }
253
+ return binaryResponse(data, format.toUpperCase());
254
+ }
255
+
231
256
  function markdownToHtml(md) {
232
257
  return md
233
258
  .replace(/^### (.+)$/gm, "<h3>$1</h3>")
@@ -239,6 +264,41 @@ function markdownToHtml(md) {
239
264
  .replace(/^/, "<p>").replace(/$/, "</p>");
240
265
  }
241
266
 
267
+ // ─── Output Format ──────────────────────────────────────────────────────────
268
+
269
+ const OUTPUT_FORMAT_SCHEMA = z.enum(["json", "yaml", "text"]).optional().default("json").describe("Output format: json (default), yaml, or text (compact human-readable)");
270
+
271
+ function dataToText(data) {
272
+ if (Array.isArray(data) && data.length > 0 && typeof data[0] === "object") {
273
+ const keys = Object.keys(data[0]);
274
+ const widths = keys.map(k => k.length);
275
+ for (const row of data) { keys.forEach((k, i) => { const len = String(row[k] ?? "").length; if (len > widths[i]) widths[i] = len; }); }
276
+ const pad = (val, i) => String(val ?? "").padEnd(widths[i]);
277
+ const header = keys.map((k, i) => pad(k, i)).join(" ");
278
+ const sep = widths.map(w => "-".repeat(w)).join(" ");
279
+ const rows = data.map(row => keys.map((k, i) => pad(row[k], i)).join(" "));
280
+ return [header, sep, ...rows].join("\n");
281
+ }
282
+ if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
283
+ return data.map(row => (row || []).map(c => String(c ?? "")).join("\t")).join("\n");
284
+ }
285
+ if (typeof data === "object" && data !== null) {
286
+ return Object.entries(data).map(([k, v]) => {
287
+ if (Array.isArray(v) && v.length > 0 && typeof v[0] === "object") return `${k}:\n${dataToText(v).split("\n").map(l => " " + l).join("\n")}`;
288
+ if (Array.isArray(v)) return `${k}: ${v.join(", ")}`;
289
+ if (typeof v === "object" && v !== null) return `${k}: ${JSON.stringify(v)}`;
290
+ return `${k}: ${v}`;
291
+ }).join("\n");
292
+ }
293
+ return String(data);
294
+ }
295
+
296
+ function formatOutput(data, format) {
297
+ if (format === "yaml") return textResponse(yaml.dump(data, { lineWidth: 120, noRefs: true }));
298
+ if (format === "text") return textResponse(dataToText(data));
299
+ return jsonResponse(data);
300
+ }
301
+
242
302
  const FILE_FIELDS = "id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,owners,shared,description,trashed,starred";
243
303
  const LIST_FIELDS = "id,name,mimeType,modifiedTime,size,parents";
244
304
 
@@ -253,15 +313,34 @@ const server = new McpServer({
253
313
 
254
314
  server.registerTool("gdrive_list_accounts", {
255
315
  description: "List all configured Google accounts with their labels and email addresses.",
256
- inputSchema: {},
316
+ inputSchema: {
317
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
318
+ },
257
319
  annotations: { readOnlyHint: true, openWorldHint: false },
258
- }, async () => {
320
+ }, async ({ outputFormat }) => {
259
321
  try {
260
322
  const config = await loadConfig();
323
+ const unresolved = Object.entries(config.accounts)
324
+ .filter(([, info]) => info.email === "migrated" || !info.email);
325
+ if (unresolved.length > 0) {
326
+ const results = await Promise.all(unresolved.map(async ([label]) => {
327
+ try {
328
+ const auth = await authorizeAccount(label);
329
+ const oauth2 = google.oauth2({ version: "v2", auth });
330
+ const userInfo = await oauth2.userinfo.get();
331
+ return { label, email: userInfo.data.email };
332
+ } catch { return null; }
333
+ }));
334
+ let configDirty = false;
335
+ for (const r of results) {
336
+ if (r?.email) { config.accounts[r.label].email = r.email; configDirty = true; }
337
+ }
338
+ if (configDirty) await saveConfig(config);
339
+ }
261
340
  const accounts = Object.entries(config.accounts).map(([label, info]) => ({
262
341
  label, email: info.email, active: label === config.active,
263
342
  }));
264
- return jsonResponse({ accounts, activeAccount: config.active });
343
+ return formatOutput({ accounts, activeAccount: config.active }, outputFormat);
265
344
  } catch (err) { return errorResponse(err.message); }
266
345
  });
267
346
 
@@ -311,10 +390,11 @@ server.registerTool("gdrive_list_files", {
311
390
  pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
312
391
  pageToken: z.string().optional().describe("Token for next page of results"),
313
392
  orderBy: z.string().optional().default("modifiedTime desc").describe("Sort order"),
393
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
314
394
  account: z.string().optional().describe("Account label (default: active account)"),
315
395
  },
316
396
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
317
- }, async ({ query, folderId, pageSize, pageToken, orderBy, account }) => {
397
+ }, async ({ query, folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
318
398
  try {
319
399
  const { drive } = await getClients(account);
320
400
  let q = "trashed = false";
@@ -325,7 +405,7 @@ server.registerTool("gdrive_list_files", {
325
405
  orderBy: orderBy || "modifiedTime desc", fields: `nextPageToken,files(${LIST_FIELDS})`,
326
406
  supportsAllDrives: true, includeItemsFromAllDrives: true,
327
407
  });
328
- return jsonResponse({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null, resultCount: (res.data.files || []).length });
408
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null, resultCount: (res.data.files || []).length }, outputFormat);
329
409
  } catch (err) { return errorResponse(err.message); }
330
410
  });
331
411
 
@@ -333,14 +413,15 @@ server.registerTool("gdrive_get_metadata", {
333
413
  description: "Get detailed metadata for a specific file in Google Drive.",
334
414
  inputSchema: {
335
415
  fileId: z.string().describe("Google Drive file ID"),
416
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
336
417
  account: z.string().optional().describe("Account label (default: active account)"),
337
418
  },
338
419
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
339
- }, async ({ fileId, account }) => {
420
+ }, async ({ fileId, outputFormat, account }) => {
340
421
  try {
341
422
  const { drive } = await getClients(account);
342
423
  const res = await drive.files.get({ fileId, fields: FILE_FIELDS, supportsAllDrives: true });
343
- return jsonResponse(res.data);
424
+ return formatOutput(res.data, outputFormat);
344
425
  } catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
345
426
  });
346
427
 
@@ -384,6 +465,46 @@ server.registerTool("gdrive_read_file", {
384
465
  } catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
385
466
  });
386
467
 
468
+ server.registerTool("gdrive_download_file", {
469
+ description: "Download a file from Google Drive to a local disk path. For Workspace files (Docs/Sheets/Slides), exports to the specified format first.",
470
+ inputSchema: {
471
+ fileId: z.string().describe("Google Drive file ID"),
472
+ savePath: z.string().describe("Local file path to save to (absolute or relative)"),
473
+ exportFormat: z.enum(["pdf", "docx", "xlsx", "pptx", "csv", "txt", "html", "markdown"]).optional().describe("Export format for Workspace files (default: pdf for Docs/Slides, csv for Sheets)"),
474
+ account: z.string().optional().describe("Account label (default: active account)"),
475
+ },
476
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
477
+ }, async ({ fileId, savePath, exportFormat, account }) => {
478
+ try {
479
+ const { drive } = await getClients(account);
480
+ const meta = await drive.files.get({ fileId, fields: "id,name,mimeType,size", supportsAllDrives: true });
481
+ const { name, mimeType, size } = meta.data;
482
+
483
+ const workspaceExportMap = {
484
+ "application/vnd.google-apps.document": { default: "pdf", mimeTypes: { pdf: "application/pdf", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", txt: "text/plain", html: "text/html", markdown: "text/html" } },
485
+ "application/vnd.google-apps.spreadsheet": { default: "csv", mimeTypes: { csv: "text/csv", xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", pdf: "application/pdf" } },
486
+ "application/vnd.google-apps.presentation": { default: "pdf", mimeTypes: { pdf: "application/pdf", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", txt: "text/plain" } },
487
+ };
488
+
489
+ const wsConfig = workspaceExportMap[mimeType];
490
+ let data;
491
+
492
+ if (wsConfig) {
493
+ const fmt = exportFormat || wsConfig.default;
494
+ const exportMime = wsConfig.mimeTypes[fmt];
495
+ if (!exportMime) return errorResponse(`Unsupported export format "${fmt}" for ${mimeType}. Available: ${Object.keys(wsConfig.mimeTypes).join(", ")}`);
496
+ const res = await drive.files.export({ fileId, mimeType: exportMime }, { responseType: "arraybuffer" });
497
+ data = res.data;
498
+ } else {
499
+ const res = await drive.files.get({ fileId, alt: "media", supportsAllDrives: true }, { responseType: "arraybuffer" });
500
+ data = res.data;
501
+ }
502
+
503
+ const result = await saveToDisk(data, savePath);
504
+ return jsonResponse({ downloaded: name, format: exportFormat || (wsConfig ? wsConfig.default : mimeType), path: result.path, size: result.size });
505
+ } catch (err) { return errorResponse(err.message); }
506
+ });
507
+
387
508
  server.registerTool("gdrive_create_file", {
388
509
  description: "Create a new file in Google Drive. Set ocrLanguage to trigger OCR on image/PDF uploads.",
389
510
  inputSchema: {
@@ -480,10 +601,11 @@ server.registerTool("gdrive_search_files", {
480
601
  modifiedAfter: z.string().optional().describe("ISO date — only files modified after this date"),
481
602
  trashed: z.boolean().optional().default(false).describe("Include trashed files (default: false)"),
482
603
  maxResults: z.number().optional().default(20).describe("Maximum results (default: 20)"),
604
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
483
605
  account: z.string().optional().describe("Account label (default: active account)"),
484
606
  },
485
607
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
486
- }, async ({ text, mimeType, modifiedAfter, trashed, maxResults, account }) => {
608
+ }, async ({ text, mimeType, modifiedAfter, trashed, maxResults, outputFormat, account }) => {
487
609
  try {
488
610
  const { drive } = await getClients(account);
489
611
  const parts = [`fullText contains '${text.replace(/'/g, "\\'")}'`];
@@ -495,7 +617,7 @@ server.registerTool("gdrive_search_files", {
495
617
  fields: `files(${LIST_FIELDS},description)`,
496
618
  supportsAllDrives: true, includeItemsFromAllDrives: true,
497
619
  });
498
- return jsonResponse({ files: res.data.files || [], resultCount: (res.data.files || []).length });
620
+ return formatOutput({ files: res.data.files || [], resultCount: (res.data.files || []).length }, outputFormat);
499
621
  } catch (err) { return errorResponse(err.message); }
500
622
  });
501
623
 
@@ -526,10 +648,11 @@ server.registerTool("gdrive_list_folder", {
526
648
  pageSize: z.number().optional().default(50).describe("Results per page (max 100, default: 50)"),
527
649
  pageToken: z.string().optional().describe("Token for next page"),
528
650
  orderBy: z.string().optional().default("folder,name").describe("Sort order (default: folders first, then by name)"),
651
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
529
652
  account: z.string().optional().describe("Account label (default: active account)"),
530
653
  },
531
654
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
532
- }, async ({ folderId, pageSize, pageToken, orderBy, account }) => {
655
+ }, async ({ folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
533
656
  try {
534
657
  const { drive } = await getClients(account);
535
658
  const res = await drive.files.list({
@@ -538,7 +661,7 @@ server.registerTool("gdrive_list_folder", {
538
661
  orderBy: orderBy || "folder,name", fields: `nextPageToken,files(${LIST_FIELDS})`,
539
662
  supportsAllDrives: true, includeItemsFromAllDrives: true,
540
663
  });
541
- return jsonResponse({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null });
664
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
542
665
  } catch (err) { return errorResponse(err.message); }
543
666
  });
544
667
 
@@ -595,7 +718,7 @@ server.registerTool("gdrive_untrash_file", {
595
718
  });
596
719
 
597
720
  server.registerTool("gdrive_empty_trash", {
598
- description: "Permanently delete ALL files in trash. Irreversible.",
721
+ description: "Permanently delete ALL files in trash. Irreversible. Note: Google returns success before files are fully purged (eventual consistency — files may still appear briefly in trash listings).",
599
722
  inputSchema: {
600
723
  account: z.string().optional().describe("Account label (default: active account)"),
601
724
  },
@@ -613,10 +736,11 @@ server.registerTool("gdrive_list_trash", {
613
736
  inputSchema: {
614
737
  pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
615
738
  pageToken: z.string().optional().describe("Token for next page"),
739
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
616
740
  account: z.string().optional().describe("Account label (default: active account)"),
617
741
  },
618
742
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
619
- }, async ({ pageSize, pageToken, account }) => {
743
+ }, async ({ pageSize, pageToken, outputFormat, account }) => {
620
744
  try {
621
745
  const { drive } = await getClients(account);
622
746
  const res = await drive.files.list({
@@ -624,7 +748,7 @@ server.registerTool("gdrive_list_trash", {
624
748
  pageToken: pageToken || undefined, fields: `nextPageToken,files(${LIST_FIELDS},trashedTime)`, orderBy: "modifiedTime desc",
625
749
  supportsAllDrives: true, includeItemsFromAllDrives: true,
626
750
  });
627
- return jsonResponse({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null });
751
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
628
752
  } catch (err) { return errorResponse(err.message); }
629
753
  });
630
754
 
@@ -661,16 +785,17 @@ server.registerTool("gdrive_list_permissions", {
661
785
  description: "List all permissions (sharing) on a file or folder.",
662
786
  inputSchema: {
663
787
  fileId: z.string().describe("File or folder ID"),
788
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
664
789
  account: z.string().optional().describe("Account label (default: active account)"),
665
790
  },
666
791
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
667
- }, async ({ fileId, account }) => {
792
+ }, async ({ fileId, outputFormat, account }) => {
668
793
  try {
669
794
  const { drive } = await getClients(account);
670
795
  const res = await drive.permissions.list({
671
796
  fileId, fields: "permissions(id,role,type,emailAddress,domain,displayName)", supportsAllDrives: true,
672
797
  });
673
- return jsonResponse({ permissions: res.data.permissions || [] });
798
+ return formatOutput({ permissions: res.data.permissions || [] }, outputFormat);
674
799
  } catch (err) { return errorResponse(err.message); }
675
800
  });
676
801
 
@@ -710,7 +835,7 @@ server.registerTool("gdrive_remove_permission", {
710
835
  });
711
836
 
712
837
  server.registerTool("gdrive_transfer_ownership", {
713
- description: "Transfer ownership of a file to another user.",
838
+ description: "Transfer ownership of a file to another user. Note: cross-organization transfers (e.g., personal to Workspace) are blocked by Google.",
714
839
  inputSchema: {
715
840
  fileId: z.string().describe("File ID to transfer"),
716
841
  email: z.string().describe("New owner's email address"),
@@ -733,17 +858,18 @@ server.registerTool("gdrive_transfer_ownership", {
733
858
  server.registerTool("gdrive_list_labels", {
734
859
  description: "List available Drive labels that can be applied to files.",
735
860
  inputSchema: {
861
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
736
862
  account: z.string().optional().describe("Account label (default: active account)"),
737
863
  },
738
864
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
739
- }, async ({ account }) => {
865
+ }, async ({ outputFormat, account }) => {
740
866
  try {
741
867
  const config = await loadConfig();
742
868
  const label = account || config.active || "primary";
743
869
  const auth = await authorizeAccount(label);
744
870
  const labels = google.drivelabels({ version: "v2", auth });
745
871
  const res = await labels.labels.list({ publishedOnly: true, view: "LABEL_VIEW_FULL" });
746
- return jsonResponse({ labels: res.data.labels || [] });
872
+ return formatOutput({ labels: res.data.labels || [] }, outputFormat);
747
873
  } catch (err) { return errorResponse(err.message); }
748
874
  });
749
875
 
@@ -751,14 +877,15 @@ server.registerTool("gdrive_list_file_labels", {
751
877
  description: "List labels currently applied to a file.",
752
878
  inputSchema: {
753
879
  fileId: z.string().describe("File ID"),
880
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
754
881
  account: z.string().optional().describe("Account label (default: active account)"),
755
882
  },
756
883
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
757
- }, async ({ fileId, account }) => {
884
+ }, async ({ fileId, outputFormat, account }) => {
758
885
  try {
759
886
  const { drive } = await getClients(account);
760
887
  const res = await drive.files.listLabels({ fileId });
761
- return jsonResponse({ labels: res.data.labels || [] });
888
+ return formatOutput({ labels: res.data.labels || [] }, outputFormat);
762
889
  } catch (err) { return errorResponse(err.message); }
763
890
  });
764
891
 
@@ -791,21 +918,22 @@ server.registerTool("gdrive_set_file_labels", {
791
918
  // ═══ DOCS TOOLS ══════════════════════════════════════════════════════════════
792
919
 
793
920
  server.registerTool("gdrive_export_doc", {
794
- description: "Export a Google Doc. Default: markdown. Also: html, text, docx, pdf.",
921
+ description: "Export a Google Doc. Default: markdown. Also: html, text, docx, pdf. Use savePath to save binary formats to disk.",
795
922
  inputSchema: {
796
923
  fileId: z.string().describe("Google Doc file ID"),
797
924
  format: z.enum(["markdown", "html", "text", "docx", "pdf"]).optional().default("markdown").describe("Export format (default: markdown)"),
925
+ savePath: z.string().optional().describe("Local file path to save binary exports (docx/pdf). If omitted, returns base64."),
798
926
  account: z.string().optional().describe("Account label (default: active account)"),
799
927
  },
800
928
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
801
- }, async ({ fileId, format, account }) => {
929
+ }, async ({ fileId, format, savePath, account }) => {
802
930
  try {
803
931
  const { drive } = await getClients(account);
804
932
  const mimeMap = { markdown: "text/html", html: "text/html", text: "text/plain", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", pdf: "application/pdf" };
805
933
  const isBinary = ["docx", "pdf"].includes(format);
806
934
  const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
807
935
  if (format === "markdown") return textResponse(turndown.turndown(res.data));
808
- if (isBinary) return binaryResponse(res.data, format.toUpperCase());
936
+ if (isBinary) return handleBinaryExport(res.data, format, savePath);
809
937
  return textResponse(res.data);
810
938
  } catch (err) { return errorResponse(err.message); }
811
939
  });
@@ -875,20 +1003,21 @@ server.registerTool("gdrive_create_sheet", {
875
1003
  });
876
1004
 
877
1005
  server.registerTool("gdrive_export_sheet", {
878
- description: "Export a Google Sheet to CSV, JSON, or XLSX.",
1006
+ description: "Export a Google Sheet to CSV, JSON, or XLSX. Use savePath to save XLSX to disk.",
879
1007
  inputSchema: {
880
1008
  fileId: z.string().describe("Google Sheet file ID"),
881
1009
  format: z.enum(["csv", "json", "xlsx"]).optional().default("csv").describe("Export format (default: csv)"),
882
1010
  sheetName: z.string().optional().describe("Specific sheet/tab name (default: first sheet). Only for csv/json."),
1011
+ savePath: z.string().optional().describe("Local file path to save XLSX export. If omitted, returns base64."),
883
1012
  account: z.string().optional().describe("Account label (default: active account)"),
884
1013
  },
885
1014
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
886
- }, async ({ fileId, format, sheetName, account }) => {
1015
+ }, async ({ fileId, format, sheetName, savePath, account }) => {
887
1016
  try {
888
1017
  const { sheets, drive } = await getClients(account);
889
1018
  if (format === "xlsx") {
890
1019
  const res = await drive.files.export({ fileId, mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, { responseType: "arraybuffer" });
891
- return binaryResponse(res.data, "XLSX");
1020
+ return handleBinaryExport(res.data, "xlsx", savePath);
892
1021
  }
893
1022
  let rangeParam = sheetName;
894
1023
  if (!rangeParam) {
@@ -916,14 +1045,15 @@ server.registerTool("gdrive_list_sheets", {
916
1045
  description: "List all sheets/tabs in a Google Spreadsheet.",
917
1046
  inputSchema: {
918
1047
  fileId: z.string().describe("Google Sheet file ID"),
1048
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
919
1049
  account: z.string().optional().describe("Account label (default: active account)"),
920
1050
  },
921
1051
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
922
- }, async ({ fileId, account }) => {
1052
+ }, async ({ fileId, outputFormat, account }) => {
923
1053
  try {
924
1054
  const { sheets } = await getClients(account);
925
1055
  const res = await sheets.spreadsheets.get({ spreadsheetId: fileId, fields: "sheets.properties" });
926
- return jsonResponse({ sheets: (res.data.sheets || []).map(s => ({ title: s.properties.title, sheetId: s.properties.sheetId, index: s.properties.index, rowCount: s.properties.gridProperties?.rowCount, columnCount: s.properties.gridProperties?.columnCount })) });
1056
+ return formatOutput({ sheets: (res.data.sheets || []).map(s => ({ title: s.properties.title, sheetId: s.properties.sheetId, index: s.properties.index, rowCount: s.properties.gridProperties?.rowCount, columnCount: s.properties.gridProperties?.columnCount })) }, outputFormat);
927
1057
  } catch (err) { return errorResponse(err.message); }
928
1058
  });
929
1059
 
@@ -933,15 +1063,16 @@ server.registerTool("gdrive_read_sheet_range", {
933
1063
  fileId: z.string().describe("Google Sheet file ID"),
934
1064
  range: z.string().describe("A1 notation range (e.g., 'Sheet1!A1:D10', 'Sales!A:A')"),
935
1065
  valueRender: z.enum(["formatted", "unformatted", "formula"]).optional().default("formatted").describe("How to render values (default: formatted)"),
1066
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
936
1067
  account: z.string().optional().describe("Account label (default: active account)"),
937
1068
  },
938
1069
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
939
- }, async ({ fileId, range, valueRender, account }) => {
1070
+ }, async ({ fileId, range, valueRender, outputFormat, account }) => {
940
1071
  try {
941
1072
  const { sheets } = await getClients(account);
942
1073
  const renderMap = { formatted: "FORMATTED_VALUE", unformatted: "UNFORMATTED_VALUE", formula: "FORMULA" };
943
1074
  const res = await sheets.spreadsheets.values.get({ spreadsheetId: fileId, range, valueRenderOption: renderMap[valueRender] || "FORMATTED_VALUE" });
944
- return jsonResponse({ range: res.data.range, values: res.data.values || [] });
1075
+ return formatOutput({ range: res.data.range, values: res.data.values || [] }, outputFormat);
945
1076
  } catch (err) { return errorResponse(err.message); }
946
1077
  });
947
1078
 
@@ -987,20 +1118,21 @@ server.registerTool("gdrive_create_slides", {
987
1118
  });
988
1119
 
989
1120
  server.registerTool("gdrive_export_slides", {
990
- description: "Export Google Slides to text, PDF, or PPTX.",
1121
+ description: "Export Google Slides to text, PDF, or PPTX. Use savePath to save binary formats to disk.",
991
1122
  inputSchema: {
992
1123
  fileId: z.string().describe("Google Slides file ID"),
993
1124
  format: z.enum(["text", "pdf", "pptx"]).optional().default("text").describe("Export format (default: text)"),
1125
+ savePath: z.string().optional().describe("Local file path to save binary exports (pdf/pptx). If omitted, returns base64."),
994
1126
  account: z.string().optional().describe("Account label (default: active account)"),
995
1127
  },
996
1128
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
997
- }, async ({ fileId, format, account }) => {
1129
+ }, async ({ fileId, format, savePath, account }) => {
998
1130
  try {
999
1131
  const { drive } = await getClients(account);
1000
1132
  const mimeMap = { text: "text/plain", pdf: "application/pdf", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" };
1001
1133
  const isBinary = ["pdf", "pptx"].includes(format);
1002
1134
  const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
1003
- if (isBinary) return binaryResponse(res.data, format.toUpperCase());
1135
+ if (isBinary) return handleBinaryExport(res.data, format, savePath);
1004
1136
  return textResponse(res.data);
1005
1137
  } catch (err) { return errorResponse(err.message); }
1006
1138
  });
@@ -1010,17 +1142,18 @@ server.registerTool("gdrive_get_slide_thumbnail", {
1010
1142
  inputSchema: {
1011
1143
  fileId: z.string().describe("Google Slides file ID"),
1012
1144
  slideIndex: z.number().optional().default(0).describe("Slide index, 0-based (default: first slide)"),
1145
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
1013
1146
  account: z.string().optional().describe("Account label (default: active account)"),
1014
1147
  },
1015
1148
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1016
- }, async ({ fileId, slideIndex, account }) => {
1149
+ }, async ({ fileId, slideIndex, outputFormat, account }) => {
1017
1150
  try {
1018
1151
  const { slides } = await getClients(account);
1019
1152
  const pres = await slides.presentations.get({ presentationId: fileId, fields: "slides.objectId" });
1020
1153
  const pages = pres.data.slides || [];
1021
1154
  if (slideIndex >= pages.length) return errorResponse(`Slide index ${slideIndex} out of range (${pages.length} slides)`);
1022
1155
  const thumb = await slides.presentations.pages.getThumbnail({ presentationId: fileId, pageObjectId: pages[slideIndex].objectId, "thumbnailProperties.mimeType": "PNG" });
1023
- return jsonResponse({ slideIndex, pageId: pages[slideIndex].objectId, thumbnailUrl: thumb.data.contentUrl, width: thumb.data.width, height: thumb.data.height });
1156
+ return formatOutput({ slideIndex, pageId: pages[slideIndex].objectId, thumbnailUrl: thumb.data.contentUrl, width: thumb.data.width, height: thumb.data.height }, outputFormat);
1024
1157
  } catch (err) { return errorResponse(err.message); }
1025
1158
  });
1026
1159