mcp-google-gdrive 0.1.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.
Files changed (4) hide show
  1. package/README.md +172 -41
  2. package/SPEC.md +8 -6
  3. package/package.json +8 -3
  4. package/src/index.js +1040 -327
package/src/index.js CHANGED
@@ -5,20 +5,27 @@ 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";
12
+ import { existsSync } from "node:fs";
11
13
  import { createInterface } from "node:readline";
12
14
 
13
15
  // ─── Constants ───────────────────────────────────────────────────────────────
14
16
 
15
- const SCOPES = ["https://www.googleapis.com/auth/drive"];
17
+ const SCOPES = [
18
+ "https://www.googleapis.com/auth/drive",
19
+ "https://www.googleapis.com/auth/documents",
20
+ "https://www.googleapis.com/auth/spreadsheets",
21
+ "https://www.googleapis.com/auth/presentations",
22
+ ];
23
+
16
24
  const CONFIG_DIR = join(homedir(), ".config", "mcp-google-gdrive");
25
+ const TOKENS_DIR = join(CONFIG_DIR, "tokens");
17
26
  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_-]+$/;
27
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
28
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
22
29
 
23
30
  const turndown = new TurndownService({
24
31
  headingStyle: "atx",
@@ -28,28 +35,40 @@ const turndown = new TurndownService({
28
35
  strongDelimiter: "**",
29
36
  });
30
37
 
31
- // ─── Auth ────────────────────────────────────────────────────────────────────
38
+ // ─── Multi-Account Auth ──────────────────────────────────────────────────────
32
39
 
33
- let authClient = null;
34
- let driveClient = null;
40
+ const authClients = new Map();
41
+ const apiClients = new Map();
35
42
 
36
- async function ensureConfigDir() {
43
+ async function ensureConfigDirs() {
37
44
  await mkdir(CONFIG_DIR, { recursive: true });
45
+ await mkdir(TOKENS_DIR, { recursive: true });
46
+ }
47
+
48
+ async function loadConfig() {
49
+ try {
50
+ return JSON.parse(await readFile(CONFIG_PATH, "utf-8"));
51
+ } catch {
52
+ return { active: "primary", accounts: {} };
53
+ }
54
+ }
55
+
56
+ async function saveConfig(config) {
57
+ await writeFile(CONFIG_PATH, JSON.stringify(config, null, 2));
38
58
  }
39
59
 
40
60
  async function loadCredentials() {
41
61
  try {
42
- const content = await readFile(CREDENTIALS_PATH, "utf-8");
43
- return JSON.parse(content);
62
+ return JSON.parse(await readFile(CREDENTIALS_PATH, "utf-8"));
44
63
  } catch (err) {
45
64
  if (err.code === "ENOENT") {
46
65
  throw new Error(
47
66
  `credentials.json not found at ${CREDENTIALS_PATH}\n` +
48
- "Setup instructions:\n" +
67
+ "Setup:\n" +
49
68
  "1. Go to https://console.cloud.google.com/\n" +
50
- "2. Create a project and enable the Google Drive API\n" +
69
+ "2. Create a project and enable Drive, Docs, Sheets, Slides APIs\n" +
51
70
  "3. Create OAuth2 desktop credentials\n" +
52
- "4. Download the JSON and save it to:\n" +
71
+ "4. Download JSON and save to:\n" +
53
72
  ` ${CREDENTIALS_PATH}`
54
73
  );
55
74
  }
@@ -57,72 +76,84 @@ async function loadCredentials() {
57
76
  }
58
77
  }
59
78
 
60
- async function loadToken() {
79
+ function tokenPath(label) {
80
+ return join(TOKENS_DIR, `${validateLabel(label)}.json`);
81
+ }
82
+
83
+ async function loadToken(label) {
61
84
  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;
85
+ return JSON.parse(await readFile(tokenPath(label), "utf-8"));
86
+ } catch {
87
+ return null;
67
88
  }
68
89
  }
69
90
 
70
- async function saveToken(token) {
71
- await writeFile(TOKEN_PATH, JSON.stringify(token, null, 2));
72
- await chmod(TOKEN_PATH, 0o600);
91
+ async function saveTokenFile(label, token) {
92
+ await writeFile(tokenPath(label), JSON.stringify(token, null, 2));
93
+ await chmod(tokenPath(label), 0o600);
73
94
  }
74
95
 
75
- async function authorize() {
76
- await ensureConfigDir();
96
+ async function createOAuth2Client() {
77
97
  const credentials = await loadCredentials();
78
-
79
98
  const { client_id, client_secret, redirect_uris } =
80
99
  credentials.installed || credentials.web;
81
-
82
- const oauth2Client = new google.auth.OAuth2(
100
+ return new google.auth.OAuth2(
83
101
  client_id,
84
102
  client_secret,
85
103
  redirect_uris?.[0] || "urn:ietf:wg:oauth:2.0:oob"
86
104
  );
105
+ }
106
+
107
+ async function authorizeAccount(label) {
108
+ if (authClients.has(label)) return authClients.get(label);
109
+
110
+ const oauth2Client = await createOAuth2Client();
111
+ const token = await loadToken(label);
87
112
 
88
- // Try loading existing token
89
- const token = await loadToken();
90
113
  if (!token) {
91
114
  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.`
115
+ `No token found for account "${label}". Run:\n` +
116
+ ` node src/index.js --auth ${label}\n`
95
117
  );
96
118
  }
97
119
 
98
120
  oauth2Client.setCredentials(token);
99
121
 
100
- // Auto-save on token refresh
101
122
  oauth2Client.on("tokens", async (newTokens) => {
102
123
  try {
103
- const existing = await loadToken();
104
- const merged = { ...existing, ...newTokens };
105
- await saveToken(merged);
124
+ const existing = await loadToken(label) || {};
125
+ await saveTokenFile(label, { ...existing, ...newTokens });
106
126
  } catch (err) {
107
- process.stderr.write(`Warning: Failed to save refreshed token: ${err.message}\n`);
127
+ process.stderr.write(`Warning: Failed to save refreshed token for ${label}: ${err.message}\n`);
108
128
  }
109
129
  });
110
130
 
131
+ authClients.set(label, oauth2Client);
111
132
  return oauth2Client;
112
133
  }
113
134
 
114
- async function interactiveAuth() {
115
- await ensureConfigDir();
116
- const credentials = await loadCredentials();
135
+ async function getClients(accountLabel) {
136
+ const config = await loadConfig();
137
+ const label = accountLabel || config.active || "primary";
117
138
 
118
- const { client_id, client_secret, redirect_uris } =
119
- credentials.installed || credentials.web;
139
+ if (apiClients.has(label)) return apiClients.get(label);
120
140
 
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
- );
141
+ const auth = await authorizeAccount(label);
142
+ const clients = {
143
+ drive: google.drive({ version: "v3", auth }),
144
+ docs: google.docs({ version: "v1", auth }),
145
+ sheets: google.sheets({ version: "v4", auth }),
146
+ slides: google.slides({ version: "v1", auth }),
147
+ label,
148
+ };
149
+
150
+ apiClients.set(label, clients);
151
+ return clients;
152
+ }
153
+
154
+ async function interactiveAuth(label) {
155
+ await ensureConfigDirs();
156
+ const oauth2Client = await createOAuth2Client();
126
157
 
127
158
  const authUrl = oauth2Client.generateAuthUrl({
128
159
  access_type: "offline",
@@ -130,16 +161,12 @@ async function interactiveAuth() {
130
161
  prompt: "consent",
131
162
  });
132
163
 
133
- process.stderr.write("\n═══ Google Drive MCP — First-Run Authorization ═══\n\n");
164
+ process.stderr.write(`\n═══ Google Drive MCP — Authorization for "${label}" ═══\n\n`);
134
165
  process.stderr.write("Open this URL in your browser:\n\n");
135
166
  process.stderr.write(` ${authUrl}\n\n`);
136
167
  process.stderr.write("After granting access, paste the authorization code below.\n\n");
137
168
 
138
- const rl = createInterface({
139
- input: process.stdin,
140
- output: process.stderr,
141
- });
142
-
169
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
143
170
  const code = await new Promise((resolve) => {
144
171
  rl.question("Authorization code: ", (answer) => {
145
172
  rl.close();
@@ -148,15 +175,25 @@ async function interactiveAuth() {
148
175
  });
149
176
 
150
177
  const { tokens } = await oauth2Client.getToken(code);
151
- await saveToken(tokens);
178
+ await saveTokenFile(label, tokens);
152
179
 
153
- process.stderr.write("\nAuthorization successful! Token saved.\n");
154
- process.stderr.write(`Token stored at: ${TOKEN_PATH}\n\n`);
155
- }
180
+ oauth2Client.setCredentials(tokens);
181
+ const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
182
+ let email = label;
183
+ try {
184
+ const userInfo = await oauth2.userinfo.get();
185
+ email = userInfo.data.email || label;
186
+ } catch { /* non-critical */ }
187
+
188
+ const config = await loadConfig();
189
+ config.accounts[label] = { email };
190
+ if (!config.active || Object.keys(config.accounts).length === 1) {
191
+ config.active = label;
192
+ }
193
+ await saveConfig(config);
156
194
 
157
- function getDrive() {
158
- if (!driveClient) throw new Error("Drive client not initialized. Auth may have failed.");
159
- return driveClient;
195
+ process.stderr.write(`\nAuthorization successful! Account "${label}" (${email}) saved.\n`);
196
+ process.stderr.write(`Token stored at: ${tokenPath(label)}\n\n`);
160
197
  }
161
198
 
162
199
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -173,305 +210,981 @@ function errorResponse(message) {
173
210
  return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
174
211
  }
175
212
 
176
- const FILE_FIELDS = "id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,owners,shared,description";
213
+ function binaryResponse(data, label) {
214
+ const buf = Buffer.from(data);
215
+ return textResponse(`[${label}, ${buf.length} bytes]\nBase64:\n${buf.toString("base64")}`);
216
+ }
217
+
218
+ function validateLabel(label) {
219
+ if (/[\/\\.]/.test(label) || label.includes("..")) {
220
+ throw new Error(`Invalid account label: "${label}". Labels must not contain /, \\, or ..`);
221
+ }
222
+ return label;
223
+ }
224
+
225
+ function sanitizeDriveId(id) {
226
+ if (id && !/^[a-zA-Z0-9_-]+$/.test(id)) {
227
+ throw new Error(`Invalid Drive ID format: "${id}"`);
228
+ }
229
+ return id;
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
+
256
+ function markdownToHtml(md) {
257
+ return md
258
+ .replace(/^### (.+)$/gm, "<h3>$1</h3>")
259
+ .replace(/^## (.+)$/gm, "<h2>$1</h2>")
260
+ .replace(/^# (.+)$/gm, "<h1>$1</h1>")
261
+ .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
262
+ .replace(/\*(.+?)\*/g, "<em>$1</em>")
263
+ .replace(/\n\n/g, "</p><p>")
264
+ .replace(/^/, "<p>").replace(/$/, "</p>");
265
+ }
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
+
302
+ const FILE_FIELDS = "id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink,owners,shared,description,trashed,starred";
177
303
  const LIST_FIELDS = "id,name,mimeType,modifiedTime,size,parents";
178
304
 
179
305
  // ─── Server ──────────────────────────────────────────────────────────────────
180
306
 
181
307
  const server = new McpServer({
182
308
  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.`);
309
+ version: "2.0.0",
310
+ });
311
+
312
+ // ═══ ACCOUNT TOOLS ═══════════════════════════════════════════════════════════
313
+
314
+ server.registerTool("gdrive_list_accounts", {
315
+ description: "List all configured Google accounts with their labels and email addresses.",
316
+ inputSchema: {
317
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
318
+ },
319
+ annotations: { readOnlyHint: true, openWorldHint: false },
320
+ }, async ({ outputFormat }) => {
321
+ try {
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; }
265
337
  }
266
- return errorResponse(err.message);
338
+ if (configDirty) await saveConfig(config);
267
339
  }
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
- }
340
+ const accounts = Object.entries(config.accounts).map(([label, info]) => ({
341
+ label, email: info.email, active: label === config.active,
342
+ }));
343
+ return formatOutput({ accounts, activeAccount: config.active }, outputFormat);
344
+ } catch (err) { return errorResponse(err.message); }
345
+ });
311
346
 
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
- }
347
+ server.registerTool("gdrive_switch_account", {
348
+ description: "Set the active Google account for subsequent tool calls.",
349
+ inputSchema: {
350
+ account: z.string().describe("Account label to make active (from gdrive_list_accounts)"),
351
+ },
352
+ annotations: { readOnlyHint: false, openWorldHint: false },
353
+ }, async ({ account }) => {
354
+ try {
355
+ const config = await loadConfig();
356
+ if (!config.accounts[account]) {
357
+ return errorResponse(`Account "${account}" not found. Available: ${Object.keys(config.accounts).join(", ") || "none"}`);
358
+ }
359
+ config.active = account;
360
+ await saveConfig(config);
361
+ return jsonResponse({ active: account, email: config.accounts[account].email });
362
+ } catch (err) { return errorResponse(err.message); }
363
+ });
319
364
 
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
- }
365
+ server.registerTool("gdrive_add_account", {
366
+ description: "Generate an authorization URL to add a new Google account.",
367
+ inputSchema: {
368
+ label: z.string().describe("Short label for this account (e.g., 'work', 'personal')"),
369
+ },
370
+ annotations: { readOnlyHint: false, openWorldHint: true },
371
+ }, async ({ label }) => {
372
+ try {
373
+ await ensureConfigDirs();
374
+ const oauth2Client = await createOAuth2Client();
375
+ const authUrl = oauth2Client.generateAuthUrl({ access_type: "offline", scope: SCOPES, prompt: "consent" });
376
+ return textResponse(
377
+ `To add account "${label}", open this URL in your browser:\n\n${authUrl}\n\n` +
378
+ `After granting access, run:\n node src/index.js --auth ${label}\nand paste the authorization code.`
379
+ );
380
+ } catch (err) { return errorResponse(err.message); }
381
+ });
327
382
 
328
- if (mimeType === "application/vnd.google-apps.drawing") {
329
- return textResponse(`[Google Drawing: ${name}] — Export not supported in read_file. Use export tools.`);
330
- }
383
+ // ═══ FILE TOOLS ══════════════════════════════════════════════════════════════
384
+
385
+ server.registerTool("gdrive_list_files", {
386
+ description: "List files in Google Drive with optional search query and folder filtering.",
387
+ inputSchema: {
388
+ query: z.string().optional().describe("Drive search query (e.g., \"name contains 'report'\")"),
389
+ folderId: z.string().optional().describe("List files in this folder only"),
390
+ pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
391
+ pageToken: z.string().optional().describe("Token for next page of results"),
392
+ orderBy: z.string().optional().default("modifiedTime desc").describe("Sort order"),
393
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
394
+ account: z.string().optional().describe("Account label (default: active account)"),
395
+ },
396
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
397
+ }, async ({ query, folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
398
+ try {
399
+ const { drive } = await getClients(account);
400
+ let q = "trashed = false";
401
+ if (folderId) q += ` and '${sanitizeDriveId(folderId)}' in parents`;
402
+ if (query) q += ` and ${query}`;
403
+ const res = await drive.files.list({
404
+ q, pageSize: Math.min(pageSize || 20, 100), pageToken: pageToken || undefined,
405
+ orderBy: orderBy || "modifiedTime desc", fields: `nextPageToken,files(${LIST_FIELDS})`,
406
+ supportsAllDrives: true, includeItemsFromAllDrives: true,
407
+ });
408
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null, resultCount: (res.data.files || []).length }, outputFormat);
409
+ } catch (err) { return errorResponse(err.message); }
410
+ });
331
411
 
332
- if (mimeType && mimeType.startsWith("application/vnd.google-apps.")) {
333
- return errorResponse(`Cannot read Google Workspace type: ${mimeType}. File: ${name}`);
334
- }
412
+ server.registerTool("gdrive_get_metadata", {
413
+ description: "Get detailed metadata for a specific file in Google Drive.",
414
+ inputSchema: {
415
+ fileId: z.string().describe("Google Drive file ID"),
416
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
417
+ account: z.string().optional().describe("Account label (default: active account)"),
418
+ },
419
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
420
+ }, async ({ fileId, outputFormat, account }) => {
421
+ try {
422
+ const { drive } = await getClients(account);
423
+ const res = await drive.files.get({ fileId, fields: FILE_FIELDS, supportsAllDrives: true });
424
+ return formatOutput(res.data, outputFormat);
425
+ } catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
426
+ });
335
427
 
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
- }
428
+ server.registerTool("gdrive_read_file", {
429
+ description: "Read file content. Auto-detects Workspace types: Docs→Markdown, Sheets→CSV, Slides→text. Binary under 10MB as base64.",
430
+ inputSchema: {
431
+ fileId: z.string().describe("Google Drive file ID"),
432
+ account: z.string().optional().describe("Account label (default: active account)"),
433
+ },
434
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
435
+ }, async ({ fileId, account }) => {
436
+ try {
437
+ const { drive } = await getClients(account);
438
+ const meta = await drive.files.get({ fileId, fields: "id,name,mimeType,size,webViewLink", supportsAllDrives: true });
439
+ const { mimeType, name, size } = meta.data;
348
440
 
349
- // Download regular file
350
- const res = await drive.files.get(
351
- { fileId, alt: "media" },
352
- { responseType: mimeType?.startsWith("text/") ? "text" : "arraybuffer" }
353
- );
441
+ if (mimeType === "application/vnd.google-apps.document") {
442
+ const res = await drive.files.export({ fileId, mimeType: "text/html" }, { responseType: "text" });
443
+ return textResponse(turndown.turndown(res.data));
444
+ }
445
+ if (mimeType === "application/vnd.google-apps.spreadsheet") {
446
+ const res = await drive.files.export({ fileId, mimeType: "text/csv" }, { responseType: "text" });
447
+ return textResponse(res.data);
448
+ }
449
+ if (mimeType === "application/vnd.google-apps.presentation") {
450
+ const res = await drive.files.export({ fileId, mimeType: "text/plain" }, { responseType: "text" });
451
+ return textResponse(res.data);
452
+ }
453
+ if (mimeType?.startsWith("application/vnd.google-apps.")) {
454
+ return errorResponse(`Unsupported Workspace type: ${mimeType}. File: ${name}`);
455
+ }
354
456
 
355
- if (mimeType?.startsWith("text/")) {
356
- return textResponse(res.data);
357
- }
457
+ const fileSize = parseInt(size, 10) || 0;
458
+ if (fileSize > MAX_FILE_SIZE) {
459
+ return jsonResponse({ warning: "File exceeds 10MB. Metadata only.", name, mimeType, size: fileSize, webViewLink: meta.data.webViewLink });
460
+ }
358
461
 
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);
462
+ const res = await drive.files.get({ fileId, alt: "media" }, { responseType: mimeType?.startsWith("text/") ? "text" : "arraybuffer" });
463
+ if (mimeType?.startsWith("text/")) return textResponse(res.data);
464
+ return binaryResponse(res.data, `Binary: ${name} (${mimeType})`);
465
+ } catch (err) { return errorResponse(err.code === 404 ? `File not found: ${fileId}` : err.message); }
466
+ });
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;
367
501
  }
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
502
 
396
- const fileMetadata = { name };
397
- if (parentFolderId) fileMetadata.parents = [parentFolderId];
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
+
508
+ server.registerTool("gdrive_create_file", {
509
+ description: "Create a new file in Google Drive. Set ocrLanguage to trigger OCR on image/PDF uploads.",
510
+ inputSchema: {
511
+ name: z.string().describe("File name including extension"),
512
+ content: z.string().describe("File content (text)"),
513
+ mimeType: z.string().optional().default("text/plain").describe("MIME type of content"),
514
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
515
+ ocrLanguage: z.string().optional().describe("OCR language hint (e.g., 'en'). Triggers OCR on image/PDF uploads"),
516
+ account: z.string().optional().describe("Account label (default: active account)"),
517
+ },
518
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
519
+ }, async ({ name, content, mimeType, parentFolderId, ocrLanguage, account }) => {
520
+ try {
521
+ const { drive } = await getClients(account);
522
+ const metadata = { name };
523
+ if (parentFolderId) metadata.parents = [parentFolderId];
524
+ const params = { requestBody: metadata, media: { mimeType: mimeType || "text/plain", body: content }, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true };
525
+ if (ocrLanguage) params.ocrLanguage = ocrLanguage;
526
+ const res = await drive.files.create(params);
527
+ return jsonResponse(res.data);
528
+ } catch (err) { return errorResponse(err.message); }
529
+ });
530
+
531
+ server.registerTool("gdrive_update_file", {
532
+ description: "Update an existing file's content, name, or description.",
533
+ inputSchema: {
534
+ fileId: z.string().describe("File ID to update"),
535
+ content: z.string().optional().describe("New file content"),
536
+ name: z.string().optional().describe("New file name"),
537
+ description: z.string().optional().describe("New file description"),
538
+ mimeType: z.string().optional().describe("MIME type of new content"),
539
+ account: z.string().optional().describe("Account label (default: active account)"),
540
+ },
541
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
542
+ }, async ({ fileId, content, name, description, mimeType, account }) => {
543
+ try {
544
+ const { drive } = await getClients(account);
545
+ const params = { fileId, supportsAllDrives: true, fields: "id,name,mimeType,modifiedTime,webViewLink" };
546
+ const metadata = {};
547
+ if (name) metadata.name = name;
548
+ if (description !== undefined) metadata.description = description;
549
+ if (Object.keys(metadata).length) params.requestBody = metadata;
550
+ if (content) params.media = { mimeType: mimeType || "text/plain", body: content };
551
+ const res = await drive.files.update(params);
552
+ return jsonResponse(res.data);
553
+ } catch (err) { return errorResponse(err.message); }
554
+ });
398
555
 
399
- const media = {
400
- mimeType: mimeType || "text/plain",
401
- body: content,
402
- };
556
+ server.registerTool("gdrive_copy_file", {
557
+ description: "Copy a file, optionally to a different folder with a new name.",
558
+ inputSchema: {
559
+ fileId: z.string().describe("Source file ID"),
560
+ name: z.string().optional().describe("Name for the copy (default: 'Copy of {original}')"),
561
+ parentFolderId: z.string().optional().describe("Destination folder ID"),
562
+ account: z.string().optional().describe("Account label (default: active account)"),
563
+ },
564
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
565
+ }, async ({ fileId, name, parentFolderId, account }) => {
566
+ try {
567
+ const { drive } = await getClients(account);
568
+ const body = {};
569
+ if (name) body.name = name;
570
+ if (parentFolderId) body.parents = [parentFolderId];
571
+ const res = await drive.files.copy({ fileId, requestBody: body, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true });
572
+ return jsonResponse(res.data);
573
+ } catch (err) { return errorResponse(err.message); }
574
+ });
403
575
 
404
- const res = await drive.files.create({
405
- requestBody: fileMetadata,
406
- media,
407
- fields: "id,name,mimeType,webViewLink",
408
- });
576
+ server.registerTool("gdrive_move_file", {
577
+ description: "Move a file to a different folder.",
578
+ inputSchema: {
579
+ fileId: z.string().describe("File ID to move"),
580
+ destinationFolderId: z.string().describe("Target folder ID"),
581
+ account: z.string().optional().describe("Account label (default: active account)"),
582
+ },
583
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
584
+ }, async ({ fileId, destinationFolderId, account }) => {
585
+ try {
586
+ const { drive } = await getClients(account);
587
+ const file = await drive.files.get({ fileId, fields: "parents", supportsAllDrives: true });
588
+ const res = await drive.files.update({
589
+ fileId, addParents: destinationFolderId, removeParents: (file.data.parents || []).join(","),
590
+ fields: "id,name,parents,webViewLink", supportsAllDrives: true,
591
+ });
592
+ return jsonResponse(res.data);
593
+ } catch (err) { return errorResponse(err.message); }
594
+ });
409
595
 
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);
596
+ server.registerTool("gdrive_search_files", {
597
+ description: "Full-text search across file names and content in Google Drive.",
598
+ inputSchema: {
599
+ text: z.string().describe("Search text (searches file names and content)"),
600
+ mimeType: z.string().optional().describe("Filter by MIME type (e.g., 'application/pdf')"),
601
+ modifiedAfter: z.string().optional().describe("ISO date — only files modified after this date"),
602
+ trashed: z.boolean().optional().default(false).describe("Include trashed files (default: false)"),
603
+ maxResults: z.number().optional().default(20).describe("Maximum results (default: 20)"),
604
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
605
+ account: z.string().optional().describe("Account label (default: active account)"),
606
+ },
607
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
608
+ }, async ({ text, mimeType, modifiedAfter, trashed, maxResults, outputFormat, account }) => {
609
+ try {
610
+ const { drive } = await getClients(account);
611
+ const parts = [`fullText contains '${text.replace(/'/g, "\\'")}'`];
612
+ if (!trashed) parts.push("trashed = false");
613
+ if (mimeType) parts.push(`mimeType = '${mimeType}'`);
614
+ if (modifiedAfter) parts.push(`modifiedTime > '${modifiedAfter}'`);
615
+ const res = await drive.files.list({
616
+ q: parts.join(" and "), pageSize: Math.min(maxResults || 20, 100),
617
+ fields: `files(${LIST_FIELDS},description)`,
618
+ supportsAllDrives: true, includeItemsFromAllDrives: true,
619
+ });
620
+ return formatOutput({ files: res.data.files || [], resultCount: (res.data.files || []).length }, outputFormat);
621
+ } catch (err) { return errorResponse(err.message); }
622
+ });
623
+
624
+ // ═══ FOLDER TOOLS ════════════════════════════════════════════════════════════
625
+
626
+ server.registerTool("gdrive_create_folder", {
627
+ description: "Create a new folder in Google Drive.",
628
+ inputSchema: {
629
+ name: z.string().describe("Folder name"),
630
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
631
+ account: z.string().optional().describe("Account label (default: active account)"),
632
+ },
633
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
634
+ }, async ({ name, parentFolderId, account }) => {
635
+ try {
636
+ const { drive } = await getClients(account);
637
+ const metadata = { name, mimeType: "application/vnd.google-apps.folder" };
638
+ if (parentFolderId) metadata.parents = [parentFolderId];
639
+ const res = await drive.files.create({ requestBody: metadata, fields: "id,name,mimeType,webViewLink", supportsAllDrives: true });
640
+ return jsonResponse(res.data);
641
+ } catch (err) { return errorResponse(err.message); }
642
+ });
643
+
644
+ server.registerTool("gdrive_list_folder", {
645
+ description: "List contents of a specific folder.",
646
+ inputSchema: {
647
+ folderId: z.string().describe("Folder ID to list contents of"),
648
+ pageSize: z.number().optional().default(50).describe("Results per page (max 100, default: 50)"),
649
+ pageToken: z.string().optional().describe("Token for next page"),
650
+ orderBy: z.string().optional().default("folder,name").describe("Sort order (default: folders first, then by name)"),
651
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
652
+ account: z.string().optional().describe("Account label (default: active account)"),
653
+ },
654
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
655
+ }, async ({ folderId, pageSize, pageToken, orderBy, outputFormat, account }) => {
656
+ try {
657
+ const { drive } = await getClients(account);
658
+ const res = await drive.files.list({
659
+ q: `'${sanitizeDriveId(folderId)}' in parents and trashed = false`,
660
+ pageSize: Math.min(pageSize || 50, 100), pageToken: pageToken || undefined,
661
+ orderBy: orderBy || "folder,name", fields: `nextPageToken,files(${LIST_FIELDS})`,
662
+ supportsAllDrives: true, includeItemsFromAllDrives: true,
663
+ });
664
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
665
+ } catch (err) { return errorResponse(err.message); }
666
+ });
667
+
668
+ server.registerTool("gdrive_delete_folder", {
669
+ description: "Delete a folder (move to trash or permanently delete).",
670
+ inputSchema: {
671
+ folderId: z.string().describe("Folder ID to delete"),
672
+ permanent: z.boolean().optional().default(false).describe("true = permanent delete, false = move to trash (default)"),
673
+ account: z.string().optional().describe("Account label (default: active account)"),
674
+ },
675
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
676
+ }, async ({ folderId, permanent, account }) => {
677
+ try {
678
+ const { drive } = await getClients(account);
679
+ if (permanent) {
680
+ await drive.files.delete({ fileId: folderId, supportsAllDrives: true });
681
+ return textResponse(`Permanently deleted folder ${folderId}`);
416
682
  }
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();
683
+ await drive.files.update({ fileId: folderId, requestBody: { trashed: true }, supportsAllDrives: true });
684
+ return textResponse(`Moved folder ${folderId} to trash`);
685
+ } catch (err) { return errorResponse(err.message); }
686
+ });
441
687
 
442
- const fileMetadata = {
443
- name,
444
- mimeType: "application/vnd.google-apps.folder",
445
- };
446
- if (parentFolderId) fileMetadata.parents = [parentFolderId];
688
+ // ═══ TRASH TOOLS ═════════════════════════════════════════════════════════════
447
689
 
448
- const res = await drive.files.create({
449
- requestBody: fileMetadata,
450
- fields: "id,name,mimeType,webViewLink",
451
- });
690
+ server.registerTool("gdrive_trash_file", {
691
+ description: "Move a file or folder to trash.",
692
+ inputSchema: {
693
+ fileId: z.string().describe("File or folder ID to trash"),
694
+ account: z.string().optional().describe("Account label (default: active account)"),
695
+ },
696
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
697
+ }, async ({ fileId, account }) => {
698
+ try {
699
+ const { drive } = await getClients(account);
700
+ const res = await drive.files.update({ fileId, requestBody: { trashed: true }, fields: "id,name,trashed", supportsAllDrives: true });
701
+ return jsonResponse(res.data);
702
+ } catch (err) { return errorResponse(err.message); }
703
+ });
452
704
 
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);
705
+ server.registerTool("gdrive_untrash_file", {
706
+ description: "Restore a file or folder from trash.",
707
+ inputSchema: {
708
+ fileId: z.string().describe("File or folder ID to restore"),
709
+ account: z.string().optional().describe("Account label (default: active account)"),
710
+ },
711
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
712
+ }, async ({ fileId, account }) => {
713
+ try {
714
+ const { drive } = await getClients(account);
715
+ const res = await drive.files.update({ fileId, requestBody: { trashed: false }, fields: "id,name,trashed", supportsAllDrives: true });
716
+ return jsonResponse(res.data);
717
+ } catch (err) { return errorResponse(err.message); }
718
+ });
719
+
720
+ server.registerTool("gdrive_empty_trash", {
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).",
722
+ inputSchema: {
723
+ account: z.string().optional().describe("Account label (default: active account)"),
724
+ },
725
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
726
+ }, async ({ account }) => {
727
+ try {
728
+ const { drive } = await getClients(account);
729
+ await drive.files.emptyTrash({ supportsAllDrives: true });
730
+ return textResponse("Trash emptied. All trashed files permanently deleted.");
731
+ } catch (err) { return errorResponse(err.message); }
732
+ });
733
+
734
+ server.registerTool("gdrive_list_trash", {
735
+ description: "List files currently in trash.",
736
+ inputSchema: {
737
+ pageSize: z.number().optional().default(20).describe("Results per page (max 100, default: 20)"),
738
+ pageToken: z.string().optional().describe("Token for next page"),
739
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
740
+ account: z.string().optional().describe("Account label (default: active account)"),
741
+ },
742
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
743
+ }, async ({ pageSize, pageToken, outputFormat, account }) => {
744
+ try {
745
+ const { drive } = await getClients(account);
746
+ const res = await drive.files.list({
747
+ q: "trashed = true", pageSize: Math.min(pageSize || 20, 100),
748
+ pageToken: pageToken || undefined, fields: `nextPageToken,files(${LIST_FIELDS},trashedTime)`, orderBy: "modifiedTime desc",
749
+ supportsAllDrives: true, includeItemsFromAllDrives: true,
750
+ });
751
+ return formatOutput({ files: res.data.files || [], nextPageToken: res.data.nextPageToken || null }, outputFormat);
752
+ } catch (err) { return errorResponse(err.message); }
753
+ });
754
+
755
+ // ═══ PERMISSION TOOLS ════════════════════════════════════════════════════════
756
+
757
+ server.registerTool("gdrive_share_file", {
758
+ description: "Share a file or folder with a user, group, domain, or anyone.",
759
+ inputSchema: {
760
+ fileId: z.string().describe("File or folder ID to share"),
761
+ role: z.enum(["reader", "commenter", "writer", "organizer"]).describe("Permission role"),
762
+ type: z.enum(["user", "group", "domain", "anyone"]).optional().default("user").describe("Permission type (default: user)"),
763
+ email: z.string().optional().describe("Email address (required for type=user or type=group)"),
764
+ domain: z.string().optional().describe("Domain (required for type=domain)"),
765
+ sendNotification: z.boolean().optional().default(true).describe("Send email notification (default: true)"),
766
+ message: z.string().optional().describe("Custom message in the notification email"),
767
+ account: z.string().optional().describe("Account label (default: active account)"),
768
+ },
769
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
770
+ }, async ({ fileId, role, type, email, domain, sendNotification, message, account }) => {
771
+ try {
772
+ const { drive } = await getClients(account);
773
+ const permission = { role, type };
774
+ if (email) permission.emailAddress = email;
775
+ if (domain) permission.domain = domain;
776
+ const res = await drive.permissions.create({
777
+ fileId, requestBody: permission, sendNotificationEmail: sendNotification,
778
+ emailMessage: message || undefined, fields: "id,role,type,emailAddress,domain", supportsAllDrives: true,
779
+ });
780
+ return jsonResponse(res.data);
781
+ } catch (err) { return errorResponse(err.message); }
782
+ });
783
+
784
+ server.registerTool("gdrive_list_permissions", {
785
+ description: "List all permissions (sharing) on a file or folder.",
786
+ inputSchema: {
787
+ fileId: z.string().describe("File or folder ID"),
788
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
789
+ account: z.string().optional().describe("Account label (default: active account)"),
790
+ },
791
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
792
+ }, async ({ fileId, outputFormat, account }) => {
793
+ try {
794
+ const { drive } = await getClients(account);
795
+ const res = await drive.permissions.list({
796
+ fileId, fields: "permissions(id,role,type,emailAddress,domain,displayName)", supportsAllDrives: true,
797
+ });
798
+ return formatOutput({ permissions: res.data.permissions || [] }, outputFormat);
799
+ } catch (err) { return errorResponse(err.message); }
800
+ });
801
+
802
+ server.registerTool("gdrive_update_permission", {
803
+ description: "Update a permission's role on a file or folder.",
804
+ inputSchema: {
805
+ fileId: z.string().describe("File or folder ID"),
806
+ permissionId: z.string().describe("Permission ID (from gdrive_list_permissions)"),
807
+ role: z.enum(["reader", "commenter", "writer", "organizer"]).describe("New role"),
808
+ account: z.string().optional().describe("Account label (default: active account)"),
809
+ },
810
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
811
+ }, async ({ fileId, permissionId, role, account }) => {
812
+ try {
813
+ const { drive } = await getClients(account);
814
+ const res = await drive.permissions.update({
815
+ fileId, permissionId, requestBody: { role }, fields: "id,role,type,emailAddress", supportsAllDrives: true,
816
+ });
817
+ return jsonResponse(res.data);
818
+ } catch (err) { return errorResponse(err.message); }
819
+ });
820
+
821
+ server.registerTool("gdrive_remove_permission", {
822
+ description: "Remove a permission (unshare) from a file or folder.",
823
+ inputSchema: {
824
+ fileId: z.string().describe("File or folder ID"),
825
+ permissionId: z.string().describe("Permission ID to remove"),
826
+ account: z.string().optional().describe("Account label (default: active account)"),
827
+ },
828
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
829
+ }, async ({ fileId, permissionId, account }) => {
830
+ try {
831
+ const { drive } = await getClients(account);
832
+ await drive.permissions.delete({ fileId, permissionId, supportsAllDrives: true });
833
+ return textResponse(`Permission ${permissionId} removed from ${fileId}`);
834
+ } catch (err) { return errorResponse(err.message); }
835
+ });
836
+
837
+ server.registerTool("gdrive_transfer_ownership", {
838
+ description: "Transfer ownership of a file to another user. Note: cross-organization transfers (e.g., personal to Workspace) are blocked by Google.",
839
+ inputSchema: {
840
+ fileId: z.string().describe("File ID to transfer"),
841
+ email: z.string().describe("New owner's email address"),
842
+ account: z.string().optional().describe("Account label (default: active account)"),
843
+ },
844
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
845
+ }, async ({ fileId, email, account }) => {
846
+ try {
847
+ const { drive } = await getClients(account);
848
+ const res = await drive.permissions.create({
849
+ fileId, requestBody: { role: "owner", type: "user", emailAddress: email },
850
+ transferOwnership: true, fields: "id,role,type,emailAddress", supportsAllDrives: true,
851
+ });
852
+ return jsonResponse(res.data);
853
+ } catch (err) { return errorResponse(err.message); }
854
+ });
855
+
856
+ // ═══ LABEL TOOLS ═════════════════════════════════════════════════════════════
857
+
858
+ server.registerTool("gdrive_list_labels", {
859
+ description: "List available Drive labels that can be applied to files.",
860
+ inputSchema: {
861
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
862
+ account: z.string().optional().describe("Account label (default: active account)"),
863
+ },
864
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
865
+ }, async ({ outputFormat, account }) => {
866
+ try {
867
+ const config = await loadConfig();
868
+ const label = account || config.active || "primary";
869
+ const auth = await authorizeAccount(label);
870
+ const labels = google.drivelabels({ version: "v2", auth });
871
+ const res = await labels.labels.list({ publishedOnly: true, view: "LABEL_VIEW_FULL" });
872
+ return formatOutput({ labels: res.data.labels || [] }, outputFormat);
873
+ } catch (err) { return errorResponse(err.message); }
874
+ });
875
+
876
+ server.registerTool("gdrive_list_file_labels", {
877
+ description: "List labels currently applied to a file.",
878
+ inputSchema: {
879
+ fileId: z.string().describe("File ID"),
880
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
881
+ account: z.string().optional().describe("Account label (default: active account)"),
882
+ },
883
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
884
+ }, async ({ fileId, outputFormat, account }) => {
885
+ try {
886
+ const { drive } = await getClients(account);
887
+ const res = await drive.files.listLabels({ fileId });
888
+ return formatOutput({ labels: res.data.labels || [] }, outputFormat);
889
+ } catch (err) { return errorResponse(err.message); }
890
+ });
891
+
892
+ server.registerTool("gdrive_set_file_labels", {
893
+ description: "Add, update, or remove a label on a file.",
894
+ inputSchema: {
895
+ fileId: z.string().describe("File ID"),
896
+ labelId: z.string().describe("Label ID (from gdrive_list_labels)"),
897
+ fields: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional().describe("Field values to set (key: fieldId, value: field value)"),
898
+ remove: z.boolean().optional().default(false).describe("true = remove this label from the file"),
899
+ account: z.string().optional().describe("Account label (default: active account)"),
900
+ },
901
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
902
+ }, async ({ fileId, labelId, fields, remove, account }) => {
903
+ try {
904
+ const { drive } = await getClients(account);
905
+ const mod = { labelId };
906
+ if (remove) {
907
+ mod.removeLabel = true;
908
+ } else if (fields) {
909
+ mod.fieldModifications = Object.entries(fields).map(([fieldId, value]) => ({
910
+ fieldId, setValues: value !== null ? [value] : undefined, unsetValues: value === null ? true : undefined,
911
+ }));
459
912
  }
460
- }
461
- );
913
+ const res = await drive.files.modifyLabels({ fileId, requestBody: { labelModifications: [mod] } });
914
+ return jsonResponse(res.data);
915
+ } catch (err) { return errorResponse(err.message); }
916
+ });
917
+
918
+ // ═══ DOCS TOOLS ══════════════════════════════════════════════════════════════
919
+
920
+ server.registerTool("gdrive_export_doc", {
921
+ description: "Export a Google Doc. Default: markdown. Also: html, text, docx, pdf. Use savePath to save binary formats to disk.",
922
+ inputSchema: {
923
+ fileId: z.string().describe("Google Doc file ID"),
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."),
926
+ account: z.string().optional().describe("Account label (default: active account)"),
927
+ },
928
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
929
+ }, async ({ fileId, format, savePath, account }) => {
930
+ try {
931
+ const { drive } = await getClients(account);
932
+ const mimeMap = { markdown: "text/html", html: "text/html", text: "text/plain", docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", pdf: "application/pdf" };
933
+ const isBinary = ["docx", "pdf"].includes(format);
934
+ const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
935
+ if (format === "markdown") return textResponse(turndown.turndown(res.data));
936
+ if (isBinary) return handleBinaryExport(res.data, format, savePath);
937
+ return textResponse(res.data);
938
+ } catch (err) { return errorResponse(err.message); }
939
+ });
940
+
941
+ server.registerTool("gdrive_create_doc", {
942
+ description: "Create a new Google Doc from Markdown content.",
943
+ inputSchema: {
944
+ name: z.string().describe("Document title"),
945
+ markdownContent: z.string().describe("Markdown content to convert to Google Doc"),
946
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
947
+ account: z.string().optional().describe("Account label (default: active account)"),
948
+ },
949
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
950
+ }, async ({ name, markdownContent, parentFolderId, account }) => {
951
+ try {
952
+ const { drive } = await getClients(account);
953
+ const metadata = { name, mimeType: "application/vnd.google-apps.document" };
954
+ if (parentFolderId) metadata.parents = [parentFolderId];
955
+ const res = await drive.files.create({
956
+ requestBody: metadata, media: { mimeType: "text/html", body: markdownToHtml(markdownContent) },
957
+ fields: "id,name,mimeType,webViewLink", supportsAllDrives: true,
958
+ });
959
+ return jsonResponse(res.data);
960
+ } catch (err) { return errorResponse(err.message); }
961
+ });
962
+
963
+ server.registerTool("gdrive_update_doc", {
964
+ description: "Replace a Google Doc's content with new Markdown.",
965
+ inputSchema: {
966
+ fileId: z.string().describe("Google Doc file ID"),
967
+ markdownContent: z.string().describe("New Markdown content (replaces entire document)"),
968
+ account: z.string().optional().describe("Account label (default: active account)"),
969
+ },
970
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
971
+ }, async ({ fileId, markdownContent, account }) => {
972
+ try {
973
+ const { drive } = await getClients(account);
974
+ const res = await drive.files.update({
975
+ fileId, media: { mimeType: "text/html", body: markdownToHtml(markdownContent) },
976
+ fields: "id,name,modifiedTime,webViewLink", supportsAllDrives: true,
977
+ });
978
+ return jsonResponse(res.data);
979
+ } catch (err) { return errorResponse(err.message); }
980
+ });
981
+
982
+ // ═══ SHEETS TOOLS ════════════════════════════════════════════════════════════
983
+
984
+ server.registerTool("gdrive_create_sheet", {
985
+ description: "Create a new Google Spreadsheet.",
986
+ inputSchema: {
987
+ name: z.string().describe("Spreadsheet title"),
988
+ sheetNames: z.array(z.string()).optional().describe("Sheet/tab names (default: ['Sheet1'])"),
989
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
990
+ account: z.string().optional().describe("Account label (default: active account)"),
991
+ },
992
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
993
+ }, async ({ name, sheetNames, parentFolderId, account }) => {
994
+ try {
995
+ const { sheets, drive } = await getClients(account);
996
+ const sheetProps = (sheetNames || ["Sheet1"]).map((title, i) => ({ properties: { title, index: i } }));
997
+ const res = await sheets.spreadsheets.create({ requestBody: { properties: { title: name }, sheets: sheetProps } });
998
+ if (parentFolderId) {
999
+ await drive.files.update({ fileId: res.data.spreadsheetId, addParents: parentFolderId, removeParents: "root", supportsAllDrives: true });
1000
+ }
1001
+ return jsonResponse({ spreadsheetId: res.data.spreadsheetId, title: res.data.properties.title, url: res.data.spreadsheetUrl });
1002
+ } catch (err) { return errorResponse(err.message); }
1003
+ });
1004
+
1005
+ server.registerTool("gdrive_export_sheet", {
1006
+ description: "Export a Google Sheet to CSV, JSON, or XLSX. Use savePath to save XLSX to disk.",
1007
+ inputSchema: {
1008
+ fileId: z.string().describe("Google Sheet file ID"),
1009
+ format: z.enum(["csv", "json", "xlsx"]).optional().default("csv").describe("Export format (default: csv)"),
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."),
1012
+ account: z.string().optional().describe("Account label (default: active account)"),
1013
+ },
1014
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1015
+ }, async ({ fileId, format, sheetName, savePath, account }) => {
1016
+ try {
1017
+ const { sheets, drive } = await getClients(account);
1018
+ if (format === "xlsx") {
1019
+ const res = await drive.files.export({ fileId, mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, { responseType: "arraybuffer" });
1020
+ return handleBinaryExport(res.data, "xlsx", savePath);
1021
+ }
1022
+ let rangeParam = sheetName;
1023
+ if (!rangeParam) {
1024
+ const meta = await sheets.spreadsheets.get({ spreadsheetId: fileId, fields: "sheets.properties" });
1025
+ rangeParam = meta.data.sheets?.[0]?.properties?.title || "Sheet1";
1026
+ }
1027
+ const res = await sheets.spreadsheets.values.get({ spreadsheetId: fileId, range: rangeParam, valueRenderOption: "FORMATTED_VALUE" });
1028
+ const rows = res.data.values || [];
1029
+ if (format === "json" && rows.length > 0) {
1030
+ const headers = rows[0];
1031
+ return jsonResponse(rows.slice(1).map(row => {
1032
+ const obj = {};
1033
+ headers.forEach((h, i) => { obj[h] = row[i] ?? null; });
1034
+ return obj;
1035
+ }));
1036
+ }
1037
+ return textResponse(rows.map(row => row.map(cell => {
1038
+ const s = String(cell ?? "");
1039
+ return s.includes(",") || s.includes('"') || s.includes("\n") ? `"${s.replace(/"/g, '""')}"` : s;
1040
+ }).join(",")).join("\n"));
1041
+ } catch (err) { return errorResponse(err.message); }
1042
+ });
1043
+
1044
+ server.registerTool("gdrive_list_sheets", {
1045
+ description: "List all sheets/tabs in a Google Spreadsheet.",
1046
+ inputSchema: {
1047
+ fileId: z.string().describe("Google Sheet file ID"),
1048
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
1049
+ account: z.string().optional().describe("Account label (default: active account)"),
1050
+ },
1051
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1052
+ }, async ({ fileId, outputFormat, account }) => {
1053
+ try {
1054
+ const { sheets } = await getClients(account);
1055
+ const res = await sheets.spreadsheets.get({ spreadsheetId: fileId, fields: "sheets.properties" });
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);
1057
+ } catch (err) { return errorResponse(err.message); }
1058
+ });
1059
+
1060
+ server.registerTool("gdrive_read_sheet_range", {
1061
+ description: "Read a specific range from a Google Sheet tab.",
1062
+ inputSchema: {
1063
+ fileId: z.string().describe("Google Sheet file ID"),
1064
+ range: z.string().describe("A1 notation range (e.g., 'Sheet1!A1:D10', 'Sales!A:A')"),
1065
+ valueRender: z.enum(["formatted", "unformatted", "formula"]).optional().default("formatted").describe("How to render values (default: formatted)"),
1066
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
1067
+ account: z.string().optional().describe("Account label (default: active account)"),
1068
+ },
1069
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1070
+ }, async ({ fileId, range, valueRender, outputFormat, account }) => {
1071
+ try {
1072
+ const { sheets } = await getClients(account);
1073
+ const renderMap = { formatted: "FORMATTED_VALUE", unformatted: "UNFORMATTED_VALUE", formula: "FORMULA" };
1074
+ const res = await sheets.spreadsheets.values.get({ spreadsheetId: fileId, range, valueRenderOption: renderMap[valueRender] || "FORMATTED_VALUE" });
1075
+ return formatOutput({ range: res.data.range, values: res.data.values || [] }, outputFormat);
1076
+ } catch (err) { return errorResponse(err.message); }
1077
+ });
1078
+
1079
+ server.registerTool("gdrive_write_sheet_range", {
1080
+ description: "Write data to a specific range in a Google Sheet.",
1081
+ inputSchema: {
1082
+ fileId: z.string().describe("Google Sheet file ID"),
1083
+ range: z.string().describe("A1 notation range (e.g., 'Sheet1!A1')"),
1084
+ values: z.array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))).describe("2D array of values (rows x columns)"),
1085
+ valueInput: z.enum(["raw", "user_entered"]).optional().default("user_entered").describe("How to interpret values (default: user_entered)"),
1086
+ account: z.string().optional().describe("Account label (default: active account)"),
1087
+ },
1088
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1089
+ }, async ({ fileId, range, values, valueInput, account }) => {
1090
+ try {
1091
+ const { sheets } = await getClients(account);
1092
+ const res = await sheets.spreadsheets.values.update({
1093
+ spreadsheetId: fileId, range, valueInputOption: valueInput === "raw" ? "RAW" : "USER_ENTERED", requestBody: { values },
1094
+ });
1095
+ return jsonResponse({ updatedRange: res.data.updatedRange, updatedRows: res.data.updatedRows, updatedColumns: res.data.updatedColumns, updatedCells: res.data.updatedCells });
1096
+ } catch (err) { return errorResponse(err.message); }
1097
+ });
1098
+
1099
+ // ═══ SLIDES TOOLS ════════════════════════════════════════════════════════════
1100
+
1101
+ server.registerTool("gdrive_create_slides", {
1102
+ description: "Create a new Google Slides presentation.",
1103
+ inputSchema: {
1104
+ name: z.string().describe("Presentation title"),
1105
+ parentFolderId: z.string().optional().describe("Parent folder ID (default: root)"),
1106
+ account: z.string().optional().describe("Account label (default: active account)"),
1107
+ },
1108
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1109
+ }, async ({ name, parentFolderId, account }) => {
1110
+ try {
1111
+ const { slides, drive } = await getClients(account);
1112
+ const res = await slides.presentations.create({ requestBody: { title: name } });
1113
+ if (parentFolderId) {
1114
+ await drive.files.update({ fileId: res.data.presentationId, addParents: parentFolderId, removeParents: "root", supportsAllDrives: true });
1115
+ }
1116
+ return jsonResponse({ presentationId: res.data.presentationId, title: res.data.title, url: `https://docs.google.com/presentation/d/${res.data.presentationId}` });
1117
+ } catch (err) { return errorResponse(err.message); }
1118
+ });
1119
+
1120
+ server.registerTool("gdrive_export_slides", {
1121
+ description: "Export Google Slides to text, PDF, or PPTX. Use savePath to save binary formats to disk.",
1122
+ inputSchema: {
1123
+ fileId: z.string().describe("Google Slides file ID"),
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."),
1126
+ account: z.string().optional().describe("Account label (default: active account)"),
1127
+ },
1128
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1129
+ }, async ({ fileId, format, savePath, account }) => {
1130
+ try {
1131
+ const { drive } = await getClients(account);
1132
+ const mimeMap = { text: "text/plain", pdf: "application/pdf", pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation" };
1133
+ const isBinary = ["pdf", "pptx"].includes(format);
1134
+ const res = await drive.files.export({ fileId, mimeType: mimeMap[format] }, { responseType: isBinary ? "arraybuffer" : "text" });
1135
+ if (isBinary) return handleBinaryExport(res.data, format, savePath);
1136
+ return textResponse(res.data);
1137
+ } catch (err) { return errorResponse(err.message); }
1138
+ });
1139
+
1140
+ server.registerTool("gdrive_get_slide_thumbnail", {
1141
+ description: "Get a thumbnail image URL for a specific slide.",
1142
+ inputSchema: {
1143
+ fileId: z.string().describe("Google Slides file ID"),
1144
+ slideIndex: z.number().optional().default(0).describe("Slide index, 0-based (default: first slide)"),
1145
+ outputFormat: OUTPUT_FORMAT_SCHEMA,
1146
+ account: z.string().optional().describe("Account label (default: active account)"),
1147
+ },
1148
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1149
+ }, async ({ fileId, slideIndex, outputFormat, account }) => {
1150
+ try {
1151
+ const { slides } = await getClients(account);
1152
+ const pres = await slides.presentations.get({ presentationId: fileId, fields: "slides.objectId" });
1153
+ const pages = pres.data.slides || [];
1154
+ if (slideIndex >= pages.length) return errorResponse(`Slide index ${slideIndex} out of range (${pages.length} slides)`);
1155
+ const thumb = await slides.presentations.pages.getThumbnail({ presentationId: fileId, pageObjectId: pages[slideIndex].objectId, "thumbnailProperties.mimeType": "PNG" });
1156
+ return formatOutput({ slideIndex, pageId: pages[slideIndex].objectId, thumbnailUrl: thumb.data.contentUrl, width: thumb.data.width, height: thumb.data.height }, outputFormat);
1157
+ } catch (err) { return errorResponse(err.message); }
1158
+ });
462
1159
 
463
1160
  // ─── Start ───────────────────────────────────────────────────────────────────
464
1161
 
465
1162
  async function main() {
466
- // Handle --auth flag for interactive first-run authorization
467
- if (process.argv.includes("--auth")) {
468
- await interactiveAuth();
1163
+ const authIndex = process.argv.indexOf("--auth");
1164
+ if (authIndex !== -1) {
1165
+ const label = process.argv[authIndex + 1] || "primary";
1166
+ await interactiveAuth(label);
469
1167
  process.exit(0);
470
1168
  }
471
1169
 
1170
+ await ensureConfigDirs();
1171
+ const config = await loadConfig();
1172
+
1173
+ // Migrate legacy single token.json
1174
+ if (Object.keys(config.accounts).length === 0) {
1175
+ const legacyToken = join(CONFIG_DIR, "token.json");
1176
+ if (existsSync(legacyToken)) {
1177
+ const token = JSON.parse(await readFile(legacyToken, "utf-8"));
1178
+ await saveTokenFile("primary", token);
1179
+ config.accounts.primary = { email: "migrated" };
1180
+ config.active = "primary";
1181
+ await saveConfig(config);
1182
+ process.stderr.write("Migrated legacy token.json to tokens/primary.json\n");
1183
+ }
1184
+ }
1185
+
472
1186
  try {
473
- authClient = await authorize();
474
- driveClient = google.drive({ version: "v3", auth: authClient });
1187
+ await getClients(config.active);
475
1188
  } catch (err) {
476
1189
  process.stderr.write(`\nAuth error: ${err.message}\n`);
477
1190
  process.exit(1);