@piotr-agier/google-drive-mcp 1.6.0 → 1.7.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
@@ -390,6 +390,25 @@ Add the server to your Claude Desktop configuration:
390
390
  - `role`: Role (`reader`, `commenter`, `writer`)
391
391
  - `sendNotificationEmail`: Send notification email (optional)
392
392
 
393
+ #### File Revisions (v1.7.0)
394
+ - **getRevisions** - List revisions for a file
395
+ - `fileId`: File ID
396
+ - `pageSize`: Max revisions to return (optional)
397
+
398
+ - **restoreRevision** - Restore a file from a selected revision (safety-confirmed)
399
+ - `fileId`: File ID
400
+ - `revisionId`: Revision ID to restore
401
+ - `confirm`: Must be `true` to execute restore
402
+
403
+ #### Auth Diagnostics & Scope Presets (v1.7.0)
404
+ - **authGetStatus** - Show token/scopes/auth health diagnostics (machine + human readable)
405
+ - **authListScopes** - Show configured/requested scopes, granted scopes, missing scopes, and presets
406
+ - **authTestFileAccess** - Test Drive access (optionally against a specific `fileId`)
407
+ - **authClearTokens** - Clear saved OAuth tokens (`confirm=true` required)
408
+ - **authSuggestScopePreset** - Get scope configuration instructions for a preset
409
+ - `preset`: `readonly` | `content-editor` | `full`
410
+ - `clearTokens`: optional best-effort token clear
411
+
393
412
  - **uploadFile** - Upload a local file (any type: image, audio, video, PDF, etc.) to Google Drive
394
413
  - `localPath`: Absolute path to the local file
395
414
  - `name`: File name in Drive (optional, defaults to local filename)
package/dist/index.js CHANGED
@@ -351,16 +351,8 @@ var TokenManager = class {
351
351
 
352
352
  // src/auth/server.ts
353
353
  import open from "open";
354
- var DEFAULT_SCOPES = [
355
- "https://www.googleapis.com/auth/drive",
356
- "https://www.googleapis.com/auth/drive.file",
357
- "https://www.googleapis.com/auth/drive.readonly",
358
- "https://www.googleapis.com/auth/documents",
359
- "https://www.googleapis.com/auth/spreadsheets",
360
- "https://www.googleapis.com/auth/presentations",
361
- "https://www.googleapis.com/auth/calendar",
362
- "https://www.googleapis.com/auth/calendar.events"
363
- ];
354
+
355
+ // src/auth/scopes.ts
364
356
  var SCOPE_ALIASES = {
365
357
  drive: "https://www.googleapis.com/auth/drive",
366
358
  "drive.file": "https://www.googleapis.com/auth/drive.file",
@@ -371,6 +363,21 @@ var SCOPE_ALIASES = {
371
363
  calendar: "https://www.googleapis.com/auth/calendar",
372
364
  "calendar.events": "https://www.googleapis.com/auth/calendar.events"
373
365
  };
366
+ var SCOPE_PRESETS = {
367
+ readonly: ["drive.readonly"],
368
+ "content-editor": ["drive.file", "documents", "spreadsheets", "presentations"],
369
+ full: ["drive", "documents", "spreadsheets", "presentations", "calendar", "calendar.events"]
370
+ };
371
+ var DEFAULT_SCOPES = [
372
+ "drive",
373
+ "drive.file",
374
+ "drive.readonly",
375
+ "documents",
376
+ "spreadsheets",
377
+ "presentations",
378
+ "calendar",
379
+ "calendar.events"
380
+ ].map((s) => SCOPE_ALIASES[s]);
374
381
  function resolveOAuthScopes() {
375
382
  const raw = process.env.GOOGLE_DRIVE_MCP_SCOPES?.trim();
376
383
  if (!raw) return [...DEFAULT_SCOPES];
@@ -385,6 +392,8 @@ function resolveOAuthScopes() {
385
392
  if (scopes.length === 0) return [...DEFAULT_SCOPES];
386
393
  return [...new Set(scopes)];
387
394
  }
395
+
396
+ // src/auth/server.ts
388
397
  var SCOPES = resolveOAuthScopes();
389
398
  var AuthServer = class {
390
399
  // Flag for standalone script
@@ -690,7 +699,7 @@ __export(drive_exports, {
690
699
  });
691
700
  import { z } from "zod";
692
701
  import { existsSync as existsSync2, statSync as statSync2, createReadStream } from "fs";
693
- import { mkdtemp, readFile as readFile3, writeFile as writeFile2, rm } from "fs/promises";
702
+ import { mkdtemp, readFile as readFile3, writeFile as writeFile2, rm, unlink as unlink2 } from "fs/promises";
694
703
  import { tmpdir } from "os";
695
704
  import { basename as basename2, extname as extname2, join as join3 } from "path";
696
705
  import { PDFDocument } from "pdf-lib";
@@ -1028,6 +1037,31 @@ async function splitPdfIntoChunkFiles(localPath, maxPagesPerChunk) {
1028
1037
  }
1029
1038
  return { tempDir, files };
1030
1039
  }
1040
+ var GetRevisionsSchema = z.object({
1041
+ fileId: z.string().min(1, "File ID is required"),
1042
+ pageSize: z.number().int().min(1).max(200).optional().default(50),
1043
+ pageToken: z.string().optional()
1044
+ });
1045
+ var RestoreRevisionSchema = z.object({
1046
+ fileId: z.string().min(1, "File ID is required"),
1047
+ revisionId: z.string().min(1, "Revision ID is required"),
1048
+ confirm: z.boolean().optional().default(false)
1049
+ });
1050
+ var AuthTestFileAccessSchema = z.object({
1051
+ fileId: z.string().optional()
1052
+ });
1053
+ var AuthClearTokensSchema = z.object({
1054
+ confirm: z.boolean().optional().default(false)
1055
+ });
1056
+ var AuthSetScopePresetSchema = z.object({
1057
+ preset: z.enum(["readonly", "content-editor", "full"]),
1058
+ clearTokens: z.boolean().optional().default(false)
1059
+ });
1060
+ function getGrantedScopesFromAuthClient(ctx) {
1061
+ const scopeRaw = ctx.authClient?.credentials?.scope;
1062
+ if (!scopeRaw || typeof scopeRaw !== "string") return [];
1063
+ return [...new Set(scopeRaw.split(" ").map((s) => s.trim()).filter(Boolean))];
1064
+ }
1031
1065
  var toolDefinitions = [
1032
1066
  {
1033
1067
  name: "search",
@@ -1291,6 +1325,74 @@ var toolDefinitions = [
1291
1325
  },
1292
1326
  required: ["localPath"]
1293
1327
  }
1328
+ },
1329
+ {
1330
+ name: "getRevisions",
1331
+ description: "List revisions for a file",
1332
+ inputSchema: {
1333
+ type: "object",
1334
+ properties: {
1335
+ fileId: { type: "string", description: "Google Drive file ID" },
1336
+ pageSize: { type: "number", description: "Max revisions to return (default 50, max 200)" },
1337
+ pageToken: { type: "string", description: "Page token for pagination" }
1338
+ },
1339
+ required: ["fileId"]
1340
+ }
1341
+ },
1342
+ {
1343
+ name: "restoreRevision",
1344
+ description: "Restore a file to a selected revision (creates a new head revision). Note: workspace files (Docs, Sheets, Slides) are restored via export/import and may lose some formatting.",
1345
+ inputSchema: {
1346
+ type: "object",
1347
+ properties: {
1348
+ fileId: { type: "string", description: "Google Drive file ID" },
1349
+ revisionId: { type: "string", description: "Revision ID to restore" },
1350
+ confirm: { type: "boolean", description: "Safety flag. Must be true to execute restore." }
1351
+ },
1352
+ required: ["fileId", "revisionId"]
1353
+ }
1354
+ },
1355
+ {
1356
+ name: "authGetStatus",
1357
+ description: "Show authentication/token status and scope diagnostics",
1358
+ inputSchema: { type: "object", properties: {} }
1359
+ },
1360
+ {
1361
+ name: "authListScopes",
1362
+ description: "List configured/requested scopes and currently granted scopes",
1363
+ inputSchema: { type: "object", properties: {} }
1364
+ },
1365
+ {
1366
+ name: "authTestFileAccess",
1367
+ description: "Run auth diagnostics against Drive API/file access",
1368
+ inputSchema: {
1369
+ type: "object",
1370
+ properties: {
1371
+ fileId: { type: "string", description: "Optional file ID for targeted access check" }
1372
+ }
1373
+ }
1374
+ },
1375
+ {
1376
+ name: "authClearTokens",
1377
+ description: "Clear saved OAuth tokens (requires confirm=true)",
1378
+ inputSchema: {
1379
+ type: "object",
1380
+ properties: {
1381
+ confirm: { type: "boolean", description: "Must be true to clear tokens" }
1382
+ }
1383
+ }
1384
+ },
1385
+ {
1386
+ name: "authSuggestScopePreset",
1387
+ description: "Get scope configuration instructions for a preset",
1388
+ inputSchema: {
1389
+ type: "object",
1390
+ properties: {
1391
+ preset: { type: "string", enum: ["readonly", "content-editor", "full"], description: "Scope preset" },
1392
+ clearTokens: { type: "boolean", description: "Also clear saved tokens now" }
1393
+ },
1394
+ required: ["preset"]
1395
+ }
1294
1396
  }
1295
1397
  ];
1296
1398
  async function handleTool(toolName, args, ctx) {
@@ -1308,6 +1410,7 @@ async function handleTool(toolName, args, ctx) {
1308
1410
  pageSize: Math.min(pageSize || 50, 100),
1309
1411
  pageToken,
1310
1412
  fields: "nextPageToken, files(id, name, mimeType, modifiedTime, size)",
1413
+ corpora: "allDrives",
1311
1414
  includeItemsFromAllDrives: true,
1312
1415
  supportsAllDrives: true
1313
1416
  });
@@ -1916,6 +2019,249 @@ ${lines.join("\n")}`
1916
2019
  }
1917
2020
  }
1918
2021
  }
2022
+ case "getRevisions": {
2023
+ const validation = GetRevisionsSchema.safeParse(args);
2024
+ if (!validation.success) return errorResponse(validation.error.errors[0].message);
2025
+ const data = validation.data;
2026
+ const response = await ctx.getDrive().revisions.list({
2027
+ fileId: data.fileId,
2028
+ pageSize: data.pageSize,
2029
+ pageToken: data.pageToken,
2030
+ fields: "nextPageToken,revisions(id,modifiedTime,lastModifyingUser(displayName,emailAddress),keepForever,size,originalFilename)"
2031
+ });
2032
+ const revisions = response.data.revisions || [];
2033
+ if (revisions.length === 0) {
2034
+ return { content: [{ type: "text", text: `No revisions found for file ${data.fileId}.` }], isError: false };
2035
+ }
2036
+ const lines = revisions.map((r) => {
2037
+ const who = r.lastModifyingUser?.displayName || r.lastModifyingUser?.emailAddress || "unknown";
2038
+ return `- ${r.id}: ${r.modifiedTime || "unknown-time"} by ${who}${r.keepForever ? " [kept]" : ""}`;
2039
+ });
2040
+ let text = `Revisions for file ${data.fileId}:
2041
+ ${lines.join("\n")}`;
2042
+ if (response.data.nextPageToken) {
2043
+ text += `
2044
+
2045
+ More revisions available. Use pageToken="${response.data.nextPageToken}" to fetch the next page.`;
2046
+ }
2047
+ return {
2048
+ content: [{ type: "text", text }],
2049
+ isError: false
2050
+ };
2051
+ }
2052
+ case "restoreRevision": {
2053
+ const validation = RestoreRevisionSchema.safeParse(args);
2054
+ if (!validation.success) return errorResponse(validation.error.errors[0].message);
2055
+ const data = validation.data;
2056
+ if (!data.confirm) {
2057
+ return errorResponse("Refusing restore: set confirm=true to restore a revision.");
2058
+ }
2059
+ try {
2060
+ const current = await ctx.getDrive().files.get({
2061
+ fileId: data.fileId,
2062
+ fields: "name,mimeType",
2063
+ supportsAllDrives: true
2064
+ });
2065
+ const fileMimeType = current.data.mimeType || "";
2066
+ const isWorkspaceFile = fileMimeType.startsWith("application/vnd.google-apps.");
2067
+ let revisionBody;
2068
+ let uploadMimeType;
2069
+ if (isWorkspaceFile) {
2070
+ const revision = await ctx.getDrive().revisions.get({
2071
+ fileId: data.fileId,
2072
+ revisionId: data.revisionId,
2073
+ fields: "id,exportLinks"
2074
+ });
2075
+ const exportLinks = revision.data.exportLinks || {};
2076
+ const formatMap = GOOGLE_WORKSPACE_EXPORT_FORMATS[fileMimeType];
2077
+ const editableMimes = formatMap ? Object.entries(formatMap).filter(([ext]) => ext !== "pdf").map(([, mime]) => mime) : [];
2078
+ const selectedMime = editableMimes.find((m) => exportLinks[m]) || Object.keys(exportLinks).find((m) => m !== "application/pdf") || Object.keys(exportLinks)[0];
2079
+ if (!selectedMime || !exportLinks[selectedMime]) {
2080
+ return errorResponse("Selected revision has no usable export links for restore.");
2081
+ }
2082
+ uploadMimeType = selectedMime;
2083
+ const exportResponse = await ctx.authClient.request({ url: exportLinks[selectedMime], responseType: "stream" });
2084
+ revisionBody = exportResponse.data;
2085
+ } else {
2086
+ const revision = await ctx.getDrive().revisions.get(
2087
+ { fileId: data.fileId, revisionId: data.revisionId, alt: "media" },
2088
+ { responseType: "stream" }
2089
+ );
2090
+ revisionBody = revision.data;
2091
+ uploadMimeType = fileMimeType || "application/octet-stream";
2092
+ }
2093
+ await ctx.getDrive().files.update({
2094
+ fileId: data.fileId,
2095
+ media: {
2096
+ mimeType: uploadMimeType,
2097
+ body: revisionBody
2098
+ },
2099
+ supportsAllDrives: true
2100
+ });
2101
+ const restoreMsg = `Restored file ${data.fileId} (${current.data.name || "unnamed"}) from revision ${data.revisionId}.`;
2102
+ const workspaceWarning = isWorkspaceFile ? "\n\nWarning: This workspace file was restored via export/import. Some formatting or features (e.g. comments, suggestions, version history metadata) may have been lost." : "";
2103
+ return {
2104
+ content: [{
2105
+ type: "text",
2106
+ text: restoreMsg + workspaceWarning
2107
+ }],
2108
+ isError: false
2109
+ };
2110
+ } catch (err) {
2111
+ return errorResponse(`Failed to restore revision: ${err instanceof Error ? err.message : String(err)}`);
2112
+ }
2113
+ }
2114
+ case "authGetStatus": {
2115
+ const tokenPath = getSecureTokenPath();
2116
+ const tokenFileExists = existsSync2(tokenPath);
2117
+ let requestedScopes;
2118
+ try {
2119
+ requestedScopes = resolveOAuthScopes();
2120
+ } catch (e) {
2121
+ return errorResponse(`Invalid scope configuration: ${e instanceof Error ? e.message : String(e)}`);
2122
+ }
2123
+ const grantedScopes = getGrantedScopesFromAuthClient(ctx);
2124
+ const missingScopes = requestedScopes.filter((s) => !grantedScopes.includes(s));
2125
+ const expiryDate = ctx.authClient?.credentials?.expiry_date;
2126
+ const expiresInSec = expiryDate ? Math.floor((expiryDate - Date.now()) / 1e3) : null;
2127
+ const payload = {
2128
+ tokenFilePath: tokenPath,
2129
+ tokenFileExists,
2130
+ hasAccessToken: !!ctx.authClient?.credentials?.access_token,
2131
+ hasRefreshToken: !!ctx.authClient?.credentials?.refresh_token,
2132
+ expiryDate: expiryDate || null,
2133
+ expiresInSec,
2134
+ requestedScopes,
2135
+ grantedScopes,
2136
+ missingScopes
2137
+ };
2138
+ const status = !tokenFileExists || !payload.hasRefreshToken ? "needs_reauth" : missingScopes.length > 0 ? "scope_mismatch" : "ok";
2139
+ let text = `Auth status (${status}):
2140
+ ${JSON.stringify(payload, null, 2)}
2141
+
2142
+ Summary: token file ${tokenFileExists ? "found" : "missing"}, missing scopes=${missingScopes.length}.`;
2143
+ if (grantedScopes.length === 0 && payload.hasAccessToken) {
2144
+ text += "\nNote: granted scopes may appear empty when the token was loaded from disk. This does not necessarily indicate missing permissions.";
2145
+ }
2146
+ return {
2147
+ content: [{ type: "text", text }],
2148
+ isError: false
2149
+ };
2150
+ }
2151
+ case "authListScopes": {
2152
+ let requestedScopes;
2153
+ try {
2154
+ requestedScopes = resolveOAuthScopes();
2155
+ } catch (e) {
2156
+ return errorResponse(`Invalid scope configuration: ${e instanceof Error ? e.message : String(e)}`);
2157
+ }
2158
+ const grantedScopes = getGrantedScopesFromAuthClient(ctx);
2159
+ const missingScopes = requestedScopes.filter((s) => !grantedScopes.includes(s));
2160
+ const presetsResolved = Object.fromEntries(
2161
+ Object.entries(SCOPE_PRESETS).map(([k, v]) => [k, v.map((s) => SCOPE_ALIASES[s])])
2162
+ );
2163
+ let text = `Scopes:
2164
+ ${JSON.stringify({ requestedScopes, grantedScopes, missingScopes, presets: presetsResolved }, null, 2)}`;
2165
+ if (grantedScopes.length === 0 && !!ctx.authClient?.credentials?.access_token) {
2166
+ text += "\nNote: granted scopes may appear empty when the token was loaded from disk. This does not necessarily indicate missing permissions.";
2167
+ }
2168
+ return {
2169
+ content: [{ type: "text", text }],
2170
+ isError: false
2171
+ };
2172
+ }
2173
+ case "authTestFileAccess": {
2174
+ const validation = AuthTestFileAccessSchema.safeParse(args);
2175
+ if (!validation.success) return errorResponse(validation.error.errors[0].message);
2176
+ const data = validation.data;
2177
+ try {
2178
+ let check;
2179
+ if (data.fileId) {
2180
+ const file = await ctx.getDrive().files.get({
2181
+ fileId: data.fileId,
2182
+ fields: "id,name,mimeType,permissions",
2183
+ supportsAllDrives: true
2184
+ });
2185
+ check = { mode: "file", fileId: file.data.id, name: file.data.name, mimeType: file.data.mimeType };
2186
+ } else {
2187
+ const list = await ctx.getDrive().files.list({
2188
+ pageSize: 1,
2189
+ fields: "files(id,name,mimeType)",
2190
+ includeItemsFromAllDrives: true,
2191
+ supportsAllDrives: true
2192
+ });
2193
+ check = { mode: "list", visibleCount: list.data.files?.length || 0, sample: list.data.files?.[0] || null };
2194
+ }
2195
+ return {
2196
+ content: [{ type: "text", text: `Auth access check OK:
2197
+ ${JSON.stringify(check, null, 2)}` }],
2198
+ isError: false
2199
+ };
2200
+ } catch (e) {
2201
+ const message = e instanceof Error ? e.message : String(e);
2202
+ return {
2203
+ content: [{ type: "text", text: `Auth access check failed:
2204
+ ${JSON.stringify({ message }, null, 2)}` }],
2205
+ isError: true
2206
+ };
2207
+ }
2208
+ }
2209
+ case "authClearTokens": {
2210
+ const validation = AuthClearTokensSchema.safeParse(args);
2211
+ if (!validation.success) return errorResponse(validation.error.errors[0].message);
2212
+ const data = validation.data;
2213
+ if (!data.confirm) return errorResponse("Refusing token clear: set confirm=true.");
2214
+ const tokenPath = getSecureTokenPath();
2215
+ const existed = existsSync2(tokenPath);
2216
+ if (existed) {
2217
+ try {
2218
+ await unlink2(tokenPath);
2219
+ } catch (e) {
2220
+ const msg = e instanceof Error ? e.message : String(e);
2221
+ return errorResponse(`Failed to remove token file at ${tokenPath}: ${msg}`);
2222
+ }
2223
+ }
2224
+ return {
2225
+ content: [{ type: "text", text: `Tokens cleared. File previously ${existed ? "existed" : "did not exist"} at ${tokenPath}. Re-run auth flow before next privileged operation.` }],
2226
+ isError: false
2227
+ };
2228
+ }
2229
+ case "authSuggestScopePreset": {
2230
+ const validation = AuthSetScopePresetSchema.safeParse(args);
2231
+ if (!validation.success) return errorResponse(validation.error.errors[0].message);
2232
+ const data = validation.data;
2233
+ const aliases = SCOPE_PRESETS[data.preset];
2234
+ const resolved = aliases.map((a) => SCOPE_ALIASES[a]);
2235
+ const envValue = aliases.join(",");
2236
+ let clearMessage = "";
2237
+ if (data.clearTokens) {
2238
+ const tokenPath = getSecureTokenPath();
2239
+ try {
2240
+ await unlink2(tokenPath);
2241
+ clearMessage = `
2242
+ Tokens cleared at ${tokenPath}.`;
2243
+ } catch (_e) {
2244
+ clearMessage = `
2245
+ Tokens clear requested, but no token file removed.`;
2246
+ }
2247
+ }
2248
+ return {
2249
+ content: [{
2250
+ type: "text",
2251
+ text: `Scope preset selected: ${data.preset}
2252
+ Requested scopes: ${JSON.stringify(resolved, null, 2)}
2253
+
2254
+ Next steps:
2255
+ 1) Export scope env:
2256
+ GOOGLE_DRIVE_MCP_SCOPES=${envValue}
2257
+ 2) Restart MCP server
2258
+ 3) Run auth flow to refresh consent (if prompted)
2259
+
2260
+ This tool does not mutate process env automatically.${clearMessage}`
2261
+ }],
2262
+ isError: false
2263
+ };
2264
+ }
1919
2265
  default:
1920
2266
  return null;
1921
2267
  }