@piotr-agier/google-drive-mcp 2.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.
package/README.md CHANGED
@@ -292,6 +292,16 @@ Notes:
292
292
  `drive`, `drive.file`, `drive.readonly`, `documents`, `spreadsheets`, `presentations`, `calendar`, `calendar.events`.
293
293
  - Changing scopes usually requires re-authentication.
294
294
 
295
+ ### Auth Server Port Configuration
296
+
297
+ During OAuth authentication, a local HTTP server is started to receive the callback. By default it tries ports 3000–3004. If those conflict with other services (e.g., a dev server), you can change the starting port:
298
+
299
+ ```bash
300
+ export GOOGLE_DRIVE_MCP_AUTH_PORT=3100
301
+ ```
302
+
303
+ The server will try 5 consecutive ports starting from the configured value (e.g., 3100–3104).
304
+
295
305
  ### Token Storage
296
306
 
297
307
  Authentication tokens are stored securely following the XDG Base Directory specification:
@@ -1104,7 +1114,7 @@ OAuth credentials not found. Please provide credentials using one of these metho
1104
1114
  #### "Authentication failed" or Browser doesn't open
1105
1115
  **Possible causes:**
1106
1116
  1. **Wrong credential type**: Must be "Desktop app", not "Web application"
1107
- 2. **Port blocked**: Ports 3000-3004 must be available
1117
+ 2. **Port blocked**: Ports 3000-3004 must be available (or custom range if `GOOGLE_DRIVE_MCP_AUTH_PORT` is set)
1108
1118
  3. **Test user not added**: Add your email in OAuth consent screen
1109
1119
 
1110
1120
  **Solution:**
@@ -1112,9 +1122,12 @@ OAuth credentials not found. Please provide credentials using one of these metho
1112
1122
  # Check if ports are in use
1113
1123
  lsof -i :3000-3004
1114
1124
 
1115
- # Kill processes if needed
1125
+ # Option 1: Kill processes if needed
1116
1126
  kill -9 <PID>
1117
1127
 
1128
+ # Option 2: Use a different port range
1129
+ export GOOGLE_DRIVE_MCP_AUTH_PORT=3100
1130
+
1118
1131
  # Re-run authentication
1119
1132
  npx @piotr-agier/google-drive-mcp auth
1120
1133
  ```
@@ -1309,6 +1322,7 @@ npm run typecheck # Type checking without compilation
1309
1322
  | Variable | Description | Default | Example |
1310
1323
  |----------|-------------|---------|---------|
1311
1324
  | `GOOGLE_DRIVE_MCP_TOKEN_PATH` | Override token storage location | `~/.config/google-drive-mcp/tokens.json` | `/custom/path/tokens.json` |
1325
+ | `GOOGLE_DRIVE_MCP_AUTH_PORT` | Starting port for OAuth callback server (uses 5 consecutive ports) | `3000` | `3100` |
1312
1326
  | `DEBUG` | Enable debug logging | (disabled) | `google-drive-mcp:*` |
1313
1327
 
1314
1328
  **External Authentication** (alternative to local OAuth flow):
package/dist/index.js CHANGED
@@ -427,7 +427,14 @@ var AuthServer = class {
427
427
  this.baseOAuth2Client = oauth2Client;
428
428
  this.tokenManager = new TokenManager(oauth2Client);
429
429
  this.app = express();
430
- this.portRange = { start: 3e3, end: 3004 };
430
+ const raw = process.env.GOOGLE_DRIVE_MCP_AUTH_PORT;
431
+ const portStart = raw ? Number(raw) : 3e3;
432
+ if (!Number.isInteger(portStart) || portStart < 1 || portStart > 65531) {
433
+ throw new Error(
434
+ `Invalid GOOGLE_DRIVE_MCP_AUTH_PORT: "${raw}". Must be an integer between 1 and 65531.`
435
+ );
436
+ }
437
+ this.portRange = { start: portStart, end: portStart + 4 };
431
438
  this.setupRoutes();
432
439
  }
433
440
  setupRoutes() {
@@ -1085,7 +1092,8 @@ var AddPermissionSchema = z.object({
1085
1092
  emailAddress: z.string().email("Valid email is required"),
1086
1093
  role: z.enum(["owner", "organizer", "fileOrganizer", "writer", "commenter", "reader"]).default("reader"),
1087
1094
  type: z.enum(["user", "group", "domain", "anyone"]).default("user"),
1088
- sendNotificationEmail: z.boolean().optional().default(false)
1095
+ sendNotificationEmail: z.boolean().optional().default(false),
1096
+ emailMessage: z.string().optional()
1089
1097
  });
1090
1098
  var UpdatePermissionSchema = z.object({
1091
1099
  fileId: z.string().min(1, "File ID is required"),
@@ -1105,7 +1113,8 @@ var ShareFileSchema = z.object({
1105
1113
  fileId: z.string().min(1, "File ID is required"),
1106
1114
  emailAddress: z.string().email("Valid email is required"),
1107
1115
  role: z.enum(["writer", "commenter", "reader"]).default("reader"),
1108
- sendNotificationEmail: z.boolean().optional().default(true)
1116
+ sendNotificationEmail: z.boolean().optional().default(true),
1117
+ emailMessage: z.string().optional()
1109
1118
  });
1110
1119
  var ConvertPdfToGoogleDocSchema = z.object({
1111
1120
  fileId: z.string().min(1, "File ID is required"),
@@ -1349,7 +1358,8 @@ var toolDefinitions = [
1349
1358
  emailAddress: { type: "string", description: "Target user/group email" },
1350
1359
  role: { type: "string", enum: ["owner", "organizer", "fileOrganizer", "writer", "commenter", "reader"], description: "Permission role" },
1351
1360
  type: { type: "string", enum: ["user", "group", "domain", "anyone"], description: "Principal type" },
1352
- sendNotificationEmail: { type: "boolean", description: "Send notification email" }
1361
+ sendNotificationEmail: { type: "boolean", description: "Send notification email" },
1362
+ emailMessage: { type: "string", description: "Custom message to include in the notification email. Ignored unless sendNotificationEmail is true." }
1353
1363
  },
1354
1364
  required: ["fileId", "emailAddress"]
1355
1365
  }
@@ -1389,7 +1399,8 @@ var toolDefinitions = [
1389
1399
  fileId: { type: "string", description: "Google Drive file ID" },
1390
1400
  emailAddress: { type: "string", description: "User email" },
1391
1401
  role: { type: "string", enum: ["writer", "commenter", "reader"], description: "Access role" },
1392
- sendNotificationEmail: { type: "boolean", description: "Send notification email" }
1402
+ sendNotificationEmail: { type: "boolean", description: "Send notification email" },
1403
+ emailMessage: { type: "string", description: "Custom message to include in the notification email. Ignored unless sendNotificationEmail is true." }
1393
1404
  },
1394
1405
  required: ["fileId", "emailAddress"]
1395
1406
  }
@@ -2139,6 +2150,7 @@ ${lines.join("\n")}` }], isError: false };
2139
2150
  emailAddress: data.emailAddress
2140
2151
  },
2141
2152
  sendNotificationEmail: data.sendNotificationEmail,
2153
+ ...data.emailMessage && { emailMessage: data.emailMessage },
2142
2154
  fields: "id,type,role,emailAddress",
2143
2155
  supportsAllDrives: true
2144
2156
  });
@@ -2225,6 +2237,7 @@ ${lines.join("\n")}` }], isError: false };
2225
2237
  emailAddress: data.emailAddress
2226
2238
  },
2227
2239
  sendNotificationEmail: data.sendNotificationEmail,
2240
+ ...data.emailMessage && { emailMessage: data.emailMessage },
2228
2241
  fields: "id,type,role,emailAddress",
2229
2242
  supportsAllDrives: true
2230
2243
  });
@@ -3143,7 +3156,8 @@ var CreateGoogleDocSchema = z2.object({
3143
3156
  });
3144
3157
  var UpdateGoogleDocSchema = z2.object({
3145
3158
  documentId: z2.string().min(1, "Document ID is required"),
3146
- content: z2.string()
3159
+ content: z2.string(),
3160
+ tabId: z2.string().optional()
3147
3161
  });
3148
3162
  var GetGoogleDocContentSchema = z2.object({
3149
3163
  documentId: z2.string().min(1, "Document ID is required"),
@@ -3152,12 +3166,14 @@ var GetGoogleDocContentSchema = z2.object({
3152
3166
  var InsertTextSchema = z2.object({
3153
3167
  documentId: z2.string().min(1, "Document ID is required"),
3154
3168
  text: z2.string().min(1, "Text to insert is required"),
3155
- index: z2.number().int().min(1, "Index must be at least 1 (1-based)")
3169
+ index: z2.number().int().min(1, "Index must be at least 1 (1-based)"),
3170
+ tabId: z2.string().optional()
3156
3171
  });
3157
3172
  var DeleteRangeSchema = z2.object({
3158
3173
  documentId: z2.string().min(1, "Document ID is required"),
3159
3174
  startIndex: z2.number().int().min(1, "Start index must be at least 1"),
3160
- endIndex: z2.number().int().min(1, "End index must be at least 1")
3175
+ endIndex: z2.number().int().min(1, "End index must be at least 1"),
3176
+ tabId: z2.string().optional()
3161
3177
  }).refine((data) => data.endIndex > data.startIndex, {
3162
3178
  message: "End index must be greater than start index",
3163
3179
  path: ["endIndex"]
@@ -3298,7 +3314,8 @@ var FindAndReplaceInDocSchema = z2.object({
3298
3314
  findText: z2.string().min(1, "findText is required"),
3299
3315
  replaceText: z2.string(),
3300
3316
  matchCase: z2.boolean().optional().default(false),
3301
- dryRun: z2.boolean().optional().default(false)
3317
+ dryRun: z2.boolean().optional().default(false),
3318
+ tabId: z2.string().optional()
3302
3319
  });
3303
3320
  var AddDocumentTabSchema = z2.object({
3304
3321
  documentId: z2.string().min(1, "Document ID is required"),
@@ -3342,38 +3359,41 @@ var toolDefinitions2 = [
3342
3359
  },
3343
3360
  {
3344
3361
  name: "updateGoogleDoc",
3345
- description: "Update an existing Google Doc",
3362
+ description: "Update an existing Google Doc (replaces all content). For multi-tab docs, specify tabId to replace a single tab's content atomically; leaves other tabs untouched.",
3346
3363
  inputSchema: {
3347
3364
  type: "object",
3348
3365
  properties: {
3349
3366
  documentId: { type: "string", description: "Doc ID" },
3350
- content: { type: "string", description: "New content" }
3367
+ content: { type: "string", description: "New content" },
3368
+ tabId: { type: "string", description: "Optional. Tab ID to replace (from listDocumentTabs). If set, delete+insert run in a single atomic batchUpdate scoped to that tab." }
3351
3369
  },
3352
3370
  required: ["documentId", "content"]
3353
3371
  }
3354
3372
  },
3355
3373
  {
3356
3374
  name: "insertText",
3357
- description: "Insert text at a specific index in a Google Doc (surgical edit, doesn't replace entire doc)",
3375
+ description: "Insert text at a specific index in a Google Doc (surgical edit, doesn't replace entire doc). For multi-tab docs, specify tabId to target a specific tab.",
3358
3376
  inputSchema: {
3359
3377
  type: "object",
3360
3378
  properties: {
3361
3379
  documentId: { type: "string", description: "The document ID" },
3362
3380
  text: { type: "string", description: "Text to insert" },
3363
- index: { type: "number", description: "Position to insert at (1-based)" }
3381
+ index: { type: "number", description: "Position to insert at (1-based)" },
3382
+ tabId: { type: "string", description: "Optional. Tab ID to insert into (from listDocumentTabs). If omitted, inserts into the first/default tab." }
3364
3383
  },
3365
3384
  required: ["documentId", "text", "index"]
3366
3385
  }
3367
3386
  },
3368
3387
  {
3369
3388
  name: "deleteRange",
3370
- description: "Delete content between start and end indices in a Google Doc",
3389
+ description: "Delete content between start and end indices in a Google Doc. For multi-tab docs, specify tabId to target a specific tab.",
3371
3390
  inputSchema: {
3372
3391
  type: "object",
3373
3392
  properties: {
3374
3393
  documentId: { type: "string", description: "The document ID" },
3375
3394
  startIndex: { type: "number", description: "Start index (1-based, inclusive)" },
3376
- endIndex: { type: "number", description: "End index (exclusive)" }
3395
+ endIndex: { type: "number", description: "End index (exclusive)" },
3396
+ tabId: { type: "string", description: "Optional. Tab ID to delete from (from listDocumentTabs). If omitted, deletes from the first/default tab." }
3377
3397
  },
3378
3398
  required: ["documentId", "startIndex", "endIndex"]
3379
3399
  }
@@ -3516,7 +3536,7 @@ var toolDefinitions2 = [
3516
3536
  },
3517
3537
  {
3518
3538
  name: "findAndReplaceInDoc",
3519
- description: "Find and replace text across a Google Document. Dry-run mode counts matches from paragraph text only (may differ from actual replacements which cover tables, headers, footers, etc.)",
3539
+ description: "Find and replace text across a Google Document. Dry-run mode counts matches from paragraph text only (may differ from actual replacements which cover tables, headers, footers, etc.). For multi-tab docs, specify tabId to scope replacements to a single tab.",
3520
3540
  inputSchema: {
3521
3541
  type: "object",
3522
3542
  properties: {
@@ -3524,7 +3544,8 @@ var toolDefinitions2 = [
3524
3544
  findText: { type: "string", description: "Text to find" },
3525
3545
  replaceText: { type: "string", description: "Replacement text" },
3526
3546
  matchCase: { type: "boolean", description: "Case-sensitive match (default: false)" },
3527
- dryRun: { type: "boolean", description: "Only count approximate matches from paragraph text, do not modify document (default: false)" }
3547
+ dryRun: { type: "boolean", description: "Only count approximate matches from paragraph text, do not modify document (default: false). Ignores tabId \u2014 always scans the full document body." },
3548
+ tabId: { type: "string", description: "Optional. Tab ID to scope replacements to (from listDocumentTabs). If omitted, replaces across all tabs." }
3528
3549
  },
3529
3550
  required: ["documentId", "findText", "replaceText"]
3530
3551
  }
@@ -3838,6 +3859,43 @@ Link: ${doc.webViewLink}` }],
3838
3859
  }
3839
3860
  const a = validation.data;
3840
3861
  const docs = ctx.google.docs({ version: "v1", auth: ctx.authClient });
3862
+ if (a.tabId) {
3863
+ const document2 = await docs.documents.get({ documentId: a.documentId, includeTabsContent: true });
3864
+ const tabs = document2.data.tabs;
3865
+ const tab = tabs ? findTabById(tabs, a.tabId) : null;
3866
+ if (!tab) {
3867
+ return errorResponse(`Tab with ID "${a.tabId}" not found. Use listDocumentTabs to see available tabs.`);
3868
+ }
3869
+ const bodyContent = tab.documentTab?.body?.content;
3870
+ const lastEndIndex = bodyContent?.[bodyContent.length - 1]?.endIndex ?? 1;
3871
+ const deleteEndIndex2 = Math.max(1, lastEndIndex - 1);
3872
+ const requests = [];
3873
+ if (deleteEndIndex2 > 1) {
3874
+ requests.push({
3875
+ deleteContentRange: {
3876
+ range: { startIndex: 1, endIndex: deleteEndIndex2, tabId: a.tabId }
3877
+ }
3878
+ });
3879
+ }
3880
+ requests.push({
3881
+ insertText: { location: { index: 1, tabId: a.tabId }, text: a.content }
3882
+ });
3883
+ requests.push({
3884
+ updateParagraphStyle: {
3885
+ range: { startIndex: 1, endIndex: a.content.length + 1, tabId: a.tabId },
3886
+ paragraphStyle: { namedStyleType: "NORMAL_TEXT" },
3887
+ fields: "namedStyleType"
3888
+ }
3889
+ });
3890
+ await docs.documents.batchUpdate({
3891
+ documentId: a.documentId,
3892
+ requestBody: { requests }
3893
+ });
3894
+ return {
3895
+ content: [{ type: "text", text: `Updated Google Doc: ${document2.data.title} (tab: ${a.tabId})` }],
3896
+ isError: false
3897
+ };
3898
+ }
3841
3899
  const document = await docs.documents.get({ documentId: a.documentId });
3842
3900
  const endIndex = document.data.body?.content?.[document.data.body.content.length - 1]?.endIndex || 1;
3843
3901
  const deleteEndIndex = Math.max(1, endIndex - 1);
@@ -4118,20 +4176,22 @@ Total length: ${totalLength} characters`
4118
4176
  return errorResponse(validation.error.errors[0].message);
4119
4177
  }
4120
4178
  const a = validation.data;
4179
+ const location = { index: a.index };
4180
+ if (a.tabId) location.tabId = a.tabId;
4121
4181
  const docs = ctx.google.docs({ version: "v1", auth: ctx.authClient });
4122
4182
  await docs.documents.batchUpdate({
4123
4183
  documentId: a.documentId,
4124
4184
  requestBody: {
4125
4185
  requests: [{
4126
4186
  insertText: {
4127
- location: { index: a.index },
4187
+ location,
4128
4188
  text: a.text
4129
4189
  }
4130
4190
  }]
4131
4191
  }
4132
4192
  });
4133
4193
  return {
4134
- content: [{ type: "text", text: `Successfully inserted ${a.text.length} characters at index ${a.index}` }],
4194
+ content: [{ type: "text", text: `Successfully inserted ${a.text.length} characters at index ${a.index}${a.tabId ? ` in tab ${a.tabId}` : ""}` }],
4135
4195
  isError: false
4136
4196
  };
4137
4197
  }
@@ -4144,22 +4204,22 @@ Total length: ${totalLength} characters`
4144
4204
  if (a.endIndex <= a.startIndex) {
4145
4205
  return errorResponse("endIndex must be greater than startIndex");
4146
4206
  }
4207
+ const range = {
4208
+ startIndex: a.startIndex,
4209
+ endIndex: a.endIndex
4210
+ };
4211
+ if (a.tabId) range.tabId = a.tabId;
4147
4212
  const docs = ctx.google.docs({ version: "v1", auth: ctx.authClient });
4148
4213
  await docs.documents.batchUpdate({
4149
4214
  documentId: a.documentId,
4150
4215
  requestBody: {
4151
4216
  requests: [{
4152
- deleteContentRange: {
4153
- range: {
4154
- startIndex: a.startIndex,
4155
- endIndex: a.endIndex
4156
- }
4157
- }
4217
+ deleteContentRange: { range }
4158
4218
  }]
4159
4219
  }
4160
4220
  });
4161
4221
  return {
4162
- content: [{ type: "text", text: `Successfully deleted content from index ${a.startIndex} to ${a.endIndex}` }],
4222
+ content: [{ type: "text", text: `Successfully deleted content from index ${a.startIndex} to ${a.endIndex}${a.tabId ? ` in tab ${a.tabId}` : ""}` }],
4163
4223
  isError: false
4164
4224
  };
4165
4225
  }
@@ -4541,25 +4601,20 @@ ${text}`;
4541
4601
  isError: false
4542
4602
  };
4543
4603
  }
4604
+ const replaceAllText = {
4605
+ containsText: { text: a.findText, matchCase: a.matchCase },
4606
+ replaceText: a.replaceText
4607
+ };
4608
+ if (a.tabId) replaceAllText.tabsCriteria = { tabIds: [a.tabId] };
4544
4609
  const response = await docs.documents.batchUpdate({
4545
4610
  documentId: a.documentId,
4546
4611
  requestBody: {
4547
- requests: [
4548
- {
4549
- replaceAllText: {
4550
- containsText: {
4551
- text: a.findText,
4552
- matchCase: a.matchCase
4553
- },
4554
- replaceText: a.replaceText
4555
- }
4556
- }
4557
- ]
4612
+ requests: [{ replaceAllText }]
4558
4613
  }
4559
4614
  });
4560
4615
  const occurrences = response.data.replies?.[0]?.replaceAllText?.occurrencesChanged ?? 0;
4561
4616
  return {
4562
- content: [{ type: "text", text: `Replaced ${occurrences} occurrence(s) of "${a.findText}".` }],
4617
+ content: [{ type: "text", text: `Replaced ${occurrences} occurrence(s) of "${a.findText}"${a.tabId ? ` in tab ${a.tabId}` : ""}.` }],
4563
4618
  isError: false
4564
4619
  };
4565
4620
  }
@@ -5131,8 +5186,10 @@ Image URL: ${imageUrl}` }],
5131
5186
  const docs = ctx.google.docs({ version: "v1", auth: ctx.authClient });
5132
5187
  await docs.documents.batchUpdate({
5133
5188
  documentId: a.documentId,
5134
- // updateDocumentTabProperties is not yet in the googleapis TypeScript types — cast required
5135
- requestBody: { requests: [{ updateDocumentTabProperties: { tabId: a.tabId, tabProperties: { title: a.title }, fields: "title" } }] }
5189
+ // updateDocumentTabProperties is not yet in the googleapis TypeScript types — cast required.
5190
+ // Per Google Docs API spec: tabId lives INSIDE tabProperties (it's the tab identifier),
5191
+ // and `fields` is a FieldMask for which properties to update (excludes tabId).
5192
+ requestBody: { requests: [{ updateDocumentTabProperties: { tabProperties: { tabId: a.tabId, title: a.title }, fields: "title" } }] }
5136
5193
  });
5137
5194
  return { content: [{ type: "text", text: `Renamed tab ${a.tabId} to "${a.title}".` }], isError: false };
5138
5195
  }
@@ -8812,6 +8869,7 @@ Examples:
8812
8869
  Environment Variables:
8813
8870
  GOOGLE_DRIVE_OAUTH_CREDENTIALS Path to OAuth credentials file
8814
8871
  GOOGLE_DRIVE_MCP_TOKEN_PATH Path to store authentication tokens
8872
+ GOOGLE_DRIVE_MCP_AUTH_PORT Starting port for OAuth callback server (default: 3000, uses 5 consecutive ports)
8815
8873
 
8816
8874
  Transport Configuration:
8817
8875
  MCP_TRANSPORT Transport mode: stdio or http (default: stdio)
@@ -8837,8 +8895,9 @@ async function runAuthServer() {
8837
8895
  const authServerInstance = new AuthServer(oauth2Client);
8838
8896
  const success = await authServerInstance.start(true);
8839
8897
  if (!success && !authServerInstance.authCompletedSuccessfully) {
8898
+ const { start, end } = authServerInstance.portRange;
8840
8899
  console.error(
8841
- "Authentication failed. Could not start server or validate existing tokens. Check port availability (3000-3004) and try again."
8900
+ `Authentication failed. Could not start server or validate existing tokens. Check port availability (${start}-${end}) and try again.`
8842
8901
  );
8843
8902
  process.exit(1);
8844
8903
  } else if (authServerInstance.authCompletedSuccessfully) {