mcp-google-multi 5.1.0-alpha.4 → 5.1.0-alpha.5

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
@@ -96,6 +96,8 @@ Every read tool's `account` argument also accepts `"*"` (all configured accounts
96
96
 
97
97
  Fan-out is **read-only by design** (write tools take exactly one account), and the three read tools that save files to disk (`drive_download`, `drive_export`, `gmail_download_attachment`) are excluded so parallel accounts can't clobber the same path. `account_list` shows what's configured: alias, email, token health (`ok` / `expired_refreshable` / `needs_reauth` / `missing` / `decrypt_error`), and granted-vs-configured scopes — without ever touching token values.
98
98
 
99
+ `drive_transfer` copies or moves a file between two of your accounts: server-side share+copy when possible (the temporary read grant on the source is revoked right after; the copy gets a clean name, never "Copy of"), download+upload as the fallback. `move: true` trashes the source after a successful copy — that part is **delete-gated by write-control**, so `safe-writes` can copy but never move. Comments, revision history, and permissions don't transfer; if the fallback has to change the format (Drawings export as PNG), the result is flagged `lossy` and a requested move keeps the source intact. Native files over Google's 10MB export cap and binaries over 1GB can't take the fallback path.
100
+
99
101
  ## Escape hatch: any Workspace REST method
100
102
 
101
103
  Two eager tools cover everything the curated set doesn't: `google_api_search` finds any method in Google's API Discovery index (including APIs with no dedicated tools here, like Slides), and `google_api_call` invokes a method by its Discovery id (`drive.revisions.list`, `slides.presentations.create`, …) with path/query params and a JSON body. Calls run through your account's OAuth client and the **same write-control policy** as named tools: the read/create/update/delete class is derived from the method's HTTP verb and name (POST deletes like `batchDelete`/`clear` count as deletes), and policy globs/`GOOGLE_TOOLSETS` match the same service names as named tools (`people` counts as `contacts`, `admin_*` as `admin`; `slides`/`driveactivity`/`drivelabels`/`groupssettings` have no named service and are always available).
@@ -21,6 +21,7 @@ export declare function inferCud(name: string): Cud;
21
21
  export declare class ToolRegistry {
22
22
  private readonly server;
23
23
  readonly tools: ToolEntry[];
24
+ readonly policy: Policy;
24
25
  readonly registerTool: McpServer['registerTool'];
25
26
  private readonly revealed;
26
27
  private readonly jsonSchemaCache;
package/dist/registry.js CHANGED
@@ -5,12 +5,12 @@ import { compactResult, trimEnabled } from './trim.js';
5
5
  import { fanoutAccountField, invalidAccountsResult, parseAccountSelector, runFanout } from './fanout.js';
6
6
  const CUD_OVERRIDES = {
7
7
  drive_untrash: 'update',
8
+ drive_transfer: 'create',
8
9
  };
9
10
  const SERVICE_OVERRIDES = {
10
11
  reports_activities_list: 'admin',
11
12
  };
12
- // Read tools that write to the local filesystem: fanning the same savePath
13
- // across accounts would clobber (last write wins).
13
+ // read tools that write local files same savePath fanned across accounts would clobber
14
14
  const FANOUT_EXCLUDE = new Set(['gmail_download_attachment', 'drive_download', 'drive_export']);
15
15
  function isAccountEnum(field) {
16
16
  return field?._zod?.def?.type === 'enum';
@@ -33,6 +33,7 @@ export function inferCud(name) {
33
33
  export class ToolRegistry {
34
34
  server;
35
35
  tools = [];
36
+ policy;
36
37
  registerTool;
37
38
  revealed = new Set();
38
39
  jsonSchemaCache = new Map();
@@ -40,6 +41,7 @@ export class ToolRegistry {
40
41
  registeringMeta = false;
41
42
  constructor(server, policy) {
42
43
  this.server = server;
44
+ this.policy = policy;
43
45
  this.registerTool = ((name, config, handler) => {
44
46
  const service = SERVICE_OVERRIDES[name] ?? (name.includes('_') ? name.slice(0, name.indexOf('_')) : name);
45
47
  const cud = inferCud(name);
@@ -49,8 +51,7 @@ export class ToolRegistry {
49
51
  destructiveHint: cud === 'delete' || cud === 'update',
50
52
  ...config.annotations,
51
53
  };
52
- // Fan-out is read-only and never applies to meta tools: google_api_call's
53
- // registry cud is "read" but it executes arbitrary writes.
54
+ // never fan out meta tools: google_api_call infers cud=read but executes writes
54
55
  let inputShape = config.inputSchema ?? {};
55
56
  let baseHandler = handler;
56
57
  if (cud === 'read' && !this.registeringMeta && !FANOUT_EXCLUDE.has(name) && isAccountEnum(inputShape.account)) {
@@ -1,2 +1,12 @@
1
1
  import type { ToolRegistry } from '../registry.js';
2
2
  export declare function registerDriveTools(server: ToolRegistry): void;
3
+ export type TransferPlan = {
4
+ kind: 'binary';
5
+ } | {
6
+ kind: 'native';
7
+ exportMime: string;
8
+ convertTo?: string;
9
+ } | {
10
+ kind: 'unsupported';
11
+ };
12
+ export declare function transferExportPlan(mimeType: string | null | undefined): TransferPlan;
@@ -1,16 +1,20 @@
1
1
  import { z } from 'zod';
2
2
  import { coerceArray, coerceBoolean } from './_coerce.js';
3
3
  import { google } from 'googleapis';
4
- import { ACCOUNTS } from '../accounts.js';
4
+ import { ACCOUNTS, ACCOUNT_CONFIG } from '../accounts.js';
5
5
  import { getClient } from '../client.js';
6
6
  import { handleGoogleApiError } from './_errors.js';
7
+ import { isAllowed, writeDisabledResult } from '../write-control.js';
7
8
  import { capText } from '../trim.js';
8
9
  import * as fs from 'fs';
9
10
  import * as path from 'path';
11
+ import * as os from 'os';
12
+ import * as crypto from 'crypto';
10
13
  import { pipeline } from 'node:stream/promises';
11
14
  import mime from 'mime-types';
12
15
  const accountEnum = z.enum(ACCOUNTS);
13
16
  const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
17
+ const FALLBACK_MAX_BYTES = 1024 * 1024 * 1024; // 1GB
14
18
  const GOOGLE_WORKSPACE_TYPES = new Set([
15
19
  'application/vnd.google-apps.document',
16
20
  'application/vnd.google-apps.spreadsheet',
@@ -1067,6 +1071,134 @@ export function registerDriveTools(server) {
1067
1071
  return handleDriveError(error, account);
1068
1072
  }
1069
1073
  });
1074
+ // ─── Cross-account transfer ────────────────────────────────────────────
1075
+ server.registerTool('drive_transfer', {
1076
+ description: 'Copy or move a file from one configured account to another (server-side share+copy, with a ' +
1077
+ 'download+upload fallback). The copy is owned by the target account with a clean name ' +
1078
+ '(no "Copy of"); the temporary share on the source is revoked afterwards. Comments, ' +
1079
+ 'revision history, and permissions do not transfer. move=true trashes the source after a ' +
1080
+ 'successful copy and requires deletes to be allowed by write-control.',
1081
+ inputSchema: {
1082
+ fromAccount: accountEnum.describe('Source account alias'),
1083
+ toAccount: accountEnum.describe('Target account alias'),
1084
+ fileId: z.string().describe('File ID in the source account (folders are not supported)'),
1085
+ parentFolderId: z.string().optional().describe('Target folder ID (default: target My Drive root)'),
1086
+ newName: z.string().optional().describe('Rename the copy (default: keep the source name)'),
1087
+ move: coerceBoolean.optional().describe('Trash the source after a successful copy (delete-gated)'),
1088
+ },
1089
+ }, async ({ fromAccount, toAccount, fileId, parentFolderId, newName, move }) => {
1090
+ // op candidate is transfer_move, not transfer: allowing "drive:transfer" must not grant the delete
1091
+ const moveRef = { name: 'drive_transfer_move', service: 'drive', cud: 'delete' };
1092
+ if (move && !isAllowed(moveRef, server.policy)) {
1093
+ return writeDisabledResult(moveRef, server.policy);
1094
+ }
1095
+ if (fromAccount === toAccount) {
1096
+ return {
1097
+ content: [{
1098
+ type: 'text',
1099
+ text: JSON.stringify({
1100
+ error: 'validation_error',
1101
+ message: 'fromAccount and toAccount are the same.',
1102
+ hint: 'Use drive_copy to duplicate a file within one account.',
1103
+ retriable: false,
1104
+ }),
1105
+ }],
1106
+ isError: true,
1107
+ };
1108
+ }
1109
+ let activeAccount = fromAccount;
1110
+ try {
1111
+ const sourceAuth = await getClient(fromAccount);
1112
+ const sourceDrive = google.drive({ version: 'v3', auth: sourceAuth });
1113
+ activeAccount = toAccount;
1114
+ const targetAuth = await getClient(toAccount);
1115
+ const targetDrive = google.drive({ version: 'v3', auth: targetAuth });
1116
+ activeAccount = fromAccount;
1117
+ const meta = await sourceDrive.files.get({
1118
+ fileId,
1119
+ fields: 'id,name,mimeType,size',
1120
+ supportsAllDrives: true,
1121
+ });
1122
+ if (meta.data.mimeType === 'application/vnd.google-apps.folder') {
1123
+ return {
1124
+ content: [{
1125
+ type: 'text',
1126
+ text: JSON.stringify({
1127
+ error: 'validation_error',
1128
+ message: 'Folders cannot be transferred.',
1129
+ hint: 'Transfer files individually, or create the folder on the target with drive_create_folder.',
1130
+ retriable: false,
1131
+ }),
1132
+ }],
1133
+ isError: true,
1134
+ };
1135
+ }
1136
+ const intendedName = newName ?? meta.data.name ?? 'transferred-file';
1137
+ const targetEmail = ACCOUNT_CONFIG[toAccount].email;
1138
+ const finish = async (data, strategy, flags) => {
1139
+ let moveFailed = false;
1140
+ let moveSkipped = false;
1141
+ if (move && flags.lossy) {
1142
+ moveSkipped = true;
1143
+ }
1144
+ else if (move) {
1145
+ activeAccount = fromAccount;
1146
+ try {
1147
+ await sourceDrive.files.update({ fileId, requestBody: { trashed: true }, supportsAllDrives: true });
1148
+ }
1149
+ catch {
1150
+ moveFailed = true;
1151
+ }
1152
+ }
1153
+ return transferResult(data, strategy, fromAccount, toAccount, {
1154
+ ...flags,
1155
+ moved: move ? !moveFailed && !moveSkipped : undefined,
1156
+ moveFailed,
1157
+ moveSkipped,
1158
+ });
1159
+ };
1160
+ try {
1161
+ const { data, cleanupFailed, renameFailed } = await shareAndCopy(sourceDrive, targetDrive, fileId, targetEmail, intendedName, parentFolderId);
1162
+ return await finish(data, 'share_copy', { cleanupFailed, renameFailed });
1163
+ }
1164
+ catch {
1165
+ // fall through to download+upload
1166
+ }
1167
+ const plan = transferExportPlan(meta.data.mimeType);
1168
+ if (plan.kind === 'unsupported') {
1169
+ return {
1170
+ content: [{
1171
+ type: 'text',
1172
+ text: JSON.stringify({
1173
+ error: 'unsupported_type',
1174
+ message: `Cannot transfer "${meta.data.mimeType}": share+copy was blocked and this native type has no export fallback.`,
1175
+ retriable: false,
1176
+ }),
1177
+ }],
1178
+ isError: true,
1179
+ };
1180
+ }
1181
+ if (plan.kind === 'binary' && Number(meta.data.size ?? 0) > FALLBACK_MAX_BYTES) {
1182
+ return {
1183
+ content: [{
1184
+ type: 'text',
1185
+ text: JSON.stringify({
1186
+ error: 'too_large',
1187
+ message: `Share+copy was blocked and the file (${meta.data.size} bytes) exceeds the ${FALLBACK_MAX_BYTES} byte download+upload fallback cap (uploads are not resumable).`,
1188
+ hint: 'Use drive_download + drive_upload manually, or fix sharing between the accounts.',
1189
+ retriable: false,
1190
+ }),
1191
+ }],
1192
+ isError: true,
1193
+ };
1194
+ }
1195
+ const data = await downloadAndUpload(sourceDrive, targetDrive, fileId, meta.data.mimeType ?? null, plan, intendedName, parentFolderId, (side) => { activeAccount = (side === 'source' ? fromAccount : toAccount); });
1196
+ return await finish(data, 'download_upload', { lossy: plan.kind === 'native' && !plan.convertTo });
1197
+ }
1198
+ catch (error) {
1199
+ return handleDriveError(error, activeAccount);
1200
+ }
1201
+ });
1070
1202
  // ─── About ─────────────────────────────────────────────────────────────
1071
1203
  server.registerTool('drive_get_about', {
1072
1204
  description: 'Get Drive storage quota, user display name, and email for an account',
@@ -1092,3 +1224,152 @@ export function registerDriveTools(server) {
1092
1224
  function handleDriveError(error, account) {
1093
1225
  return handleGoogleApiError(error, account);
1094
1226
  }
1227
+ export function transferExportPlan(mimeType) {
1228
+ if (!mimeType || !mimeType.startsWith('application/vnd.google-apps.'))
1229
+ return { kind: 'binary' };
1230
+ switch (mimeType) {
1231
+ case 'application/vnd.google-apps.document':
1232
+ return {
1233
+ kind: 'native',
1234
+ exportMime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1235
+ convertTo: mimeType,
1236
+ };
1237
+ case 'application/vnd.google-apps.spreadsheet':
1238
+ return {
1239
+ kind: 'native',
1240
+ exportMime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1241
+ convertTo: mimeType,
1242
+ };
1243
+ case 'application/vnd.google-apps.presentation':
1244
+ return {
1245
+ kind: 'native',
1246
+ exportMime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1247
+ convertTo: mimeType,
1248
+ };
1249
+ case 'application/vnd.google-apps.drawing':
1250
+ return { kind: 'native', exportMime: 'image/png' };
1251
+ default:
1252
+ return { kind: 'unsupported' };
1253
+ }
1254
+ }
1255
+ function transferResult(data, strategy, from, to, flags) {
1256
+ return {
1257
+ content: [{
1258
+ type: 'text',
1259
+ text: JSON.stringify({
1260
+ id: data.id,
1261
+ name: data.name,
1262
+ mimeType: data.mimeType,
1263
+ webViewLink: data.webViewLink,
1264
+ strategy,
1265
+ from,
1266
+ to,
1267
+ ...(flags.lossy ? { lossy: true } : {}),
1268
+ ...(flags.moved !== undefined ? { moved: flags.moved } : {}),
1269
+ ...(flags.moveSkipped ? { hint: 'Move skipped: the fallback changed the file format (e.g. Drawing → PNG), so the editable source was kept. Trash it manually if intended.' } : {}),
1270
+ ...(flags.moveFailed ? { moveFailed: true, hint: 'Copy succeeded but trashing the source failed — the source file is still present.' } : {}),
1271
+ ...(flags.renameFailed ? { renameFailed: true } : {}),
1272
+ ...(flags.cleanupFailed ? { cleanupFailed: true } : {}),
1273
+ }, null, 2),
1274
+ }],
1275
+ };
1276
+ }
1277
+ async function shareAndCopy(sourceDrive, targetDrive, fileId, targetEmail, intendedName, parentFolderId) {
1278
+ const perm = await sourceDrive.permissions.create({
1279
+ fileId,
1280
+ requestBody: {
1281
+ type: 'user',
1282
+ role: 'reader',
1283
+ emailAddress: targetEmail,
1284
+ expirationTime: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
1285
+ },
1286
+ sendNotificationEmail: false,
1287
+ supportsAllDrives: true,
1288
+ fields: 'id',
1289
+ });
1290
+ let cleanupFailed = false;
1291
+ let renameFailed = false;
1292
+ let data;
1293
+ try {
1294
+ const copy = await targetDrive.files.copy({
1295
+ fileId,
1296
+ requestBody: {
1297
+ name: intendedName,
1298
+ // cross-account copy with omitted parents lands in an indeterminate place
1299
+ parents: [parentFolderId ?? 'root'],
1300
+ },
1301
+ supportsAllDrives: true,
1302
+ fields: 'id,name,mimeType,webViewLink',
1303
+ });
1304
+ data = copy.data;
1305
+ if (data.name !== intendedName && data.id) {
1306
+ // copy already succeeded — a failed cosmetic rename must not trigger the fallback
1307
+ try {
1308
+ const renamed = await targetDrive.files.update({
1309
+ fileId: data.id,
1310
+ requestBody: { name: intendedName },
1311
+ supportsAllDrives: true,
1312
+ fields: 'id,name,mimeType,webViewLink',
1313
+ });
1314
+ data = renamed.data;
1315
+ }
1316
+ catch {
1317
+ renameFailed = true;
1318
+ }
1319
+ }
1320
+ }
1321
+ finally {
1322
+ if (perm.data.id) {
1323
+ try {
1324
+ await sourceDrive.permissions.delete({ fileId, permissionId: perm.data.id, supportsAllDrives: true });
1325
+ }
1326
+ catch {
1327
+ cleanupFailed = true;
1328
+ }
1329
+ }
1330
+ }
1331
+ return { data, cleanupFailed, renameFailed };
1332
+ }
1333
+ async function downloadAndUpload(sourceDrive, targetDrive, fileId, sourceMime, plan, intendedName, parentFolderId, onSide) {
1334
+ const tmp = path.join(os.tmpdir(), `gmulti-transfer-${crypto.randomBytes(8).toString('hex')}`);
1335
+ try {
1336
+ onSide('source');
1337
+ let download;
1338
+ try {
1339
+ download =
1340
+ plan.kind === 'native'
1341
+ ? await sourceDrive.files.export({ fileId, mimeType: plan.exportMime }, { responseType: 'stream' })
1342
+ : await sourceDrive.files.get({ fileId, alt: 'media', supportsAllDrives: true }, { responseType: 'stream' });
1343
+ }
1344
+ catch (err) {
1345
+ if (plan.kind === 'native' && /too large/i.test(err.message ?? '')) {
1346
+ throw new Error("Google caps native-file exports at 10MB and share+copy was blocked — use drive_export + drive_upload manually, or google_api_call (drive.files.download).", { cause: err });
1347
+ }
1348
+ throw err;
1349
+ }
1350
+ try {
1351
+ await pipeline(download.data, fs.createWriteStream(tmp, { mode: 0o600 }));
1352
+ }
1353
+ catch (err) {
1354
+ throw new Error(`Local temp-file write failed (not a Google error): ${err.message}`, { cause: err });
1355
+ }
1356
+ onSide('target');
1357
+ const created = await targetDrive.files.create({
1358
+ requestBody: {
1359
+ name: intendedName,
1360
+ ...(plan.kind === 'native' && plan.convertTo ? { mimeType: plan.convertTo } : {}),
1361
+ ...(parentFolderId ? { parents: [parentFolderId] } : {}),
1362
+ },
1363
+ media: {
1364
+ mimeType: plan.kind === 'native' ? plan.exportMime : (sourceMime ?? 'application/octet-stream'),
1365
+ body: fs.createReadStream(tmp),
1366
+ },
1367
+ supportsAllDrives: true,
1368
+ fields: 'id,name,mimeType,webViewLink',
1369
+ });
1370
+ return created.data;
1371
+ }
1372
+ finally {
1373
+ await fs.promises.unlink(tmp).catch(() => { });
1374
+ }
1375
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-google-multi",
3
- "version": "5.1.0-alpha.4",
3
+ "version": "5.1.0-alpha.5",
4
4
  "description": "Local MCP server for Google Workspace (Gmail, Drive, Calendar, Sheets, Docs, Contacts, Tasks, Meet, Search Console, +Forms/Chat/Admin) across multiple accounts — OAuth-only, encrypted token storage, deny-by-default writes.",
5
5
  "type": "module",
6
6
  "license": "MIT",