gogcli-mcp-drive 2.23.2 → 2.25.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Extended Google Drive for Claude via gogcli — auth + full Drive support (upload, download, permissions, comments, shared drives)",
10
- "version": "2.23.2"
10
+ "version": "2.25.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli (Drive)",
16
16
  "source": "./",
17
17
  "description": "Extended Google Drive for Claude via gogcli — auth + full Drive support (upload, download, permissions, comments, shared drives)",
18
- "version": "2.23.2",
18
+ "version": "2.25.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp-drive",
3
3
  "displayName": "gogcli (Drive)",
4
- "version": "2.23.2",
4
+ "version": "2.25.0",
5
5
  "description": "Extended Google Drive for Claude via gogcli — auth + full Drive support (upload, download, permissions, comments, shared drives)",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/index.js CHANGED
@@ -31177,10 +31177,11 @@ function sanitizedEnv() {
31177
31177
  }
31178
31178
  return result;
31179
31179
  }
31180
+ var TOKEN_LEFT_BOUNDARY = "(?<![A-Za-z0-9+/])";
31180
31181
  var GOOGLE_TOKEN_PATTERNS = [
31181
- /ya29\.[A-Za-z0-9._\-]+/g,
31182
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}ya29\\.[A-Za-z0-9._\\-]+`, "g"),
31182
31183
  // OAuth2 access tokens
31183
- /1\/\/[A-Za-z0-9._\-]+/g
31184
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}1//[A-Za-z0-9._\\-]+`, "g")
31184
31185
  // OAuth2 refresh tokens
31185
31186
  ];
31186
31187
  function redactGoogleTokens(text) {
@@ -31193,6 +31194,26 @@ function redactGoogleTokens(text) {
31193
31194
  function redactSecrets2(text) {
31194
31195
  return redactGoogleTokens(redactSecrets(text));
31195
31196
  }
31197
+ var OPAQUE_FIELD_VALUE = "[A-Za-z0-9+/_-]{16,}={0,2}";
31198
+ var opaquePlaceholder = (i) => `\0gogOpaque${i}\0`;
31199
+ function redactPreservingOpaqueFields(text, fields, redact) {
31200
+ const lifted = [];
31201
+ let staged = text;
31202
+ for (const field of fields) {
31203
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31204
+ const re = new RegExp(`("${escaped}"\\s*:\\s*")(${OPAQUE_FIELD_VALUE})(")`, "g");
31205
+ staged = staged.replace(re, (_m, open, value, close) => {
31206
+ lifted.push(value);
31207
+ return `${open}${opaquePlaceholder(lifted.length - 1)}${close}`;
31208
+ });
31209
+ }
31210
+ if (lifted.length === 0) return redact(text);
31211
+ let redacted = redact(staged);
31212
+ lifted.forEach((value, i) => {
31213
+ redacted = redacted.split(opaquePlaceholder(i)).join(value);
31214
+ });
31215
+ return redacted;
31216
+ }
31196
31217
  function augmentedPath() {
31197
31218
  const home = process.env.HOME;
31198
31219
  const candidates = [
@@ -31223,19 +31244,24 @@ function formatTimeout(ms) {
31223
31244
  return `${ms}ms`;
31224
31245
  }
31225
31246
  async function spawnWithTempFiles(args, opts) {
31226
- const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
31247
+ const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
31227
31248
  const { tmpdir } = await import("node:os");
31228
31249
  const dir = await mkdtemp(join(tmpdir(), "gogcli-mcp-"));
31229
31250
  try {
31230
31251
  const argv = [];
31252
+ let seq = 0;
31231
31253
  for (const arg of args) {
31232
31254
  if (!isGogFileArg(arg)) {
31233
31255
  argv.push(arg);
31234
31256
  continue;
31235
31257
  }
31236
- const path = join(dir, `${arg.flag}.${arg.ext ?? "txt"}`);
31237
- await writeFile(path, arg.contents, { encoding: "utf8", mode: 384 });
31238
- argv.push(`--${arg.flag}=${path}`);
31258
+ const sub = join(dir, String(seq));
31259
+ seq += 1;
31260
+ await mkdir(sub, { recursive: true, mode: 448 });
31261
+ const path = join(sub, arg.filename ?? `${arg.flag}.${arg.ext ?? "txt"}`);
31262
+ const data = arg.encoding === "base64" ? Buffer.from(arg.contents, "base64") : Buffer.from(arg.contents, "utf8");
31263
+ await writeFile(path, data, { mode: 384 });
31264
+ argv.push(arg.positional ? path : `--${arg.flag}=${path}`);
31239
31265
  }
31240
31266
  return await spawnGog(argv, opts);
31241
31267
  } finally {
@@ -31320,8 +31346,9 @@ function assembleArgs(args, opts) {
31320
31346
  return fullArgs;
31321
31347
  }
31322
31348
  async function run(args, options = {}) {
31323
- const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
31324
- const redact = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
31349
+ const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full", opaqueFields } = options;
31350
+ const base = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
31351
+ const redact = opaqueFields?.length ? (text) => redactPreservingOpaqueFields(text, opaqueFields, base) : base;
31325
31352
  const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
31326
31353
  const store = activeExecutor();
31327
31354
  try {
@@ -31335,7 +31362,7 @@ async function run(args, options = {}) {
31335
31362
  }
31336
31363
  return redact(output);
31337
31364
  } catch (err) {
31338
- const message = redact(err instanceof Error ? err.message : String(err));
31365
+ const message = base(err instanceof Error ? err.message : String(err));
31339
31366
  if (isRunnerTransportError(err)) {
31340
31367
  throw new RunnerTransportError(message, err.kind, err.status);
31341
31368
  }
@@ -31743,6 +31770,7 @@ function formatAuthHealth(raw, now) {
31743
31770
  // ../gogcli-mcp/src/tools/auth.ts
31744
31771
  function registerAuthToolsWith(server, defaultServices) {
31745
31772
  const servicesDescribe = `Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "${defaultServices}". Prefer the narrowest set you need \u2014 requesting a service whose Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request with invalid_scope.`;
31773
+ const extraScopesDescribe = "Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. Use for scopes no service covers \u2014 e.g. https://www.googleapis.com/auth/bigquery.readonly, required before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE authorization with invalid_scope.";
31746
31774
  server.registerTool("gog_auth_list", {
31747
31775
  description: "List the Google accounts stored in gogcli, with their scopes. This reads local configuration only \u2014 it does not contact Google and does NOT tell you whether an account still works: a signed-out account whose refresh token expired or was revoked is listed here exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account can actually authenticate.",
31748
31776
  annotations: { readOnlyHint: true },
@@ -31792,11 +31820,14 @@ function registerAuthToolsWith(server, defaultServices) {
31792
31820
  annotations: { destructiveHint: true },
31793
31821
  inputSchema: {
31794
31822
  email: external_exports.string().describe("Google account email to authorize"),
31795
- services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
31823
+ services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
31824
+ extraScopes: external_exports.string().optional().describe(extraScopesDescribe)
31796
31825
  }
31797
- }, async ({ email: email3, services = defaultServices }) => {
31826
+ }, async ({ email: email3, services = defaultServices, extraScopes }) => {
31798
31827
  try {
31799
- return rawTextResult(await run(["auth", "add", email3, "--services", services], {
31828
+ const args = ["auth", "add", email3, "--services", services];
31829
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, "--force-consent");
31830
+ return rawTextResult(await run(args, {
31800
31831
  interactive: true,
31801
31832
  timeout: 3e5
31802
31833
  }));
@@ -31808,14 +31839,14 @@ function registerAuthToolsWith(server, defaultServices) {
31808
31839
  description: "Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any browser \u2014 no local server or terminal on the gogcli host is needed, so this works over the hosted connector where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, the browser is redirected to a localhost URL that fails to load \u2014 that is expected. They copy that full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to gog_auth_add_complete or the second step will not match this one.",
31809
31840
  inputSchema: {
31810
31841
  email: external_exports.string().describe("Google account email to authorize"),
31811
- services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
31842
+ services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
31843
+ extraScopes: external_exports.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`)
31812
31844
  }
31813
- }, async ({ email: email3, services = defaultServices }) => {
31845
+ }, async ({ email: email3, services = defaultServices, extraScopes }) => {
31814
31846
  try {
31815
- return rawTextResult(await run(
31816
- ["auth", "add", email3, "--remote", "--step", "1", "--services", services, "--force-consent"],
31817
- { redactMode: "tokens" }
31818
- ));
31847
+ const args = ["auth", "add", email3, "--remote", "--step", "1", "--services", services, "--force-consent"];
31848
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
31849
+ return rawTextResult(await run(args, { redactMode: "tokens" }));
31819
31850
  } catch (err) {
31820
31851
  return errorResult(errorText(err));
31821
31852
  }
@@ -31830,25 +31861,28 @@ function registerAuthToolsWith(server, defaultServices) {
31830
31861
  ),
31831
31862
  services: external_exports.string().optional().default(defaultServices).describe(
31832
31863
  `Services authorized \u2014 MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`
31864
+ ),
31865
+ extraScopes: external_exports.string().optional().describe(
31866
+ "Extra OAuth scope URIs \u2014 MUST match the value passed to gog_auth_add_url, for the same reason `services` must: the two steps have to describe the same grant."
31833
31867
  )
31834
31868
  }
31835
- }, async ({ email: email3, redirectUrl, services = defaultServices }) => {
31869
+ }, async ({ email: email3, redirectUrl, services = defaultServices, extraScopes }) => {
31836
31870
  try {
31837
- return rawTextResult(await run(
31838
- [
31839
- "auth",
31840
- "add",
31841
- email3,
31842
- "--remote",
31843
- "--step",
31844
- "2",
31845
- "--auth-url",
31846
- redirectUrl,
31847
- "--services",
31848
- services,
31849
- "--force-consent"
31850
- ]
31851
- ));
31871
+ const args = [
31872
+ "auth",
31873
+ "add",
31874
+ email3,
31875
+ "--remote",
31876
+ "--step",
31877
+ "2",
31878
+ "--auth-url",
31879
+ redirectUrl,
31880
+ "--services",
31881
+ services,
31882
+ "--force-consent"
31883
+ ];
31884
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
31885
+ return rawTextResult(await run(args));
31852
31886
  } catch (err) {
31853
31887
  return errorResult(errorText(err));
31854
31888
  }
@@ -32079,6 +32113,61 @@ function registerDriveTools(server) {
32079
32113
  registerRunTool(server, { service: "drive", examples: '"copy", "upload", "download", "permissions"' });
32080
32114
  }
32081
32115
 
32116
+ // ../gogcli-mcp/src/attachments.ts
32117
+ var MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
32118
+ var RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
32119
+ var RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
32120
+ var MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
32121
+ var MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor(MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3 / 4);
32122
+ var formatMiB = (bytes) => `${Math.floor(bytes / (1024 * 1024))} MiB`;
32123
+ var INLINE_ATTACHMENT_LIMITS_TEXT = `up to ${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)} per file and ${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)} in total`;
32124
+ var inlineAttachmentSchema = external_exports.object({
32125
+ filename: external_exports.string().min(1).describe(
32126
+ `Filename the recipient will see, e.g. "pendant-layouts.png". gog infers the attachment's MIME type from this extension, so give it the right one \u2014 a .png sent as "layouts" arrives as an untyped blob. Must be a single filename, not a path.`
32127
+ ),
32128
+ contentBase64: external_exports.string().min(1).describe(
32129
+ "The file's bytes, base64-encoded (standard alphabet, with padding). This is the whole point of this parameter: the bytes travel with the request, so nothing needs to exist on the gog server's filesystem."
32130
+ )
32131
+ });
32132
+ var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional().describe(
32133
+ `Attachments supplied as BYTES rather than as server-side paths \u2014 use this whenever you hold a file and the gog server does not, which is always the case on the hosted connector and on any remote deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). Can be combined with \`attach\`: the two name disjoint files (paths read on the server vs. bytes sent with the call), and both end up as ordinary attachments on the message.`
32134
+ );
32135
+ function validateFilename(filename, where) {
32136
+ if (/[/\\]/.test(filename)) {
32137
+ throw new Error(
32138
+ `${where}: filename ${JSON.stringify(filename)} must be a bare filename, not a path. Pass just the name the recipient should see, e.g. "report.pdf".`
32139
+ );
32140
+ }
32141
+ if (/[\x00-\x1f]/.test(filename) || /^\.+$/.test(filename) || filename.length > 200) {
32142
+ throw new Error(
32143
+ `${where}: filename ${JSON.stringify(filename)} is not a usable filename (no control characters, not "."/"..", 200 characters max).`
32144
+ );
32145
+ }
32146
+ }
32147
+ function decodedLength(contentBase64) {
32148
+ const buf = Buffer.from(contentBase64, "base64");
32149
+ return buf.toString("base64") === contentBase64 ? buf.length : null;
32150
+ }
32151
+ function inlineFileArg(flag, attachment, opts = {}) {
32152
+ const { filename, contentBase64 } = attachment;
32153
+ const where = opts.where ?? `attachInline entry ${JSON.stringify(filename)}`;
32154
+ validateFilename(filename, where);
32155
+ const bytes = decodedLength(contentBase64);
32156
+ if (bytes === null) {
32157
+ throw new Error(
32158
+ `${where}: contents are not valid base64. Send the standard alphabet with padding and no line breaks \u2014 the value must survive a decode/re-encode round trip unchanged.`
32159
+ );
32160
+ }
32161
+ if (bytes > MAX_INLINE_ATTACHMENT_BYTES) {
32162
+ throw new Error(
32163
+ `${where}: ${bytes} bytes exceeds the ${MAX_INLINE_ATTACHMENT_BYTES}-byte (${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)}) per-file limit for inline content. Upload it to Drive and link to it instead, or send it from a local (stdio) deployment using a real server-side path.`
32164
+ );
32165
+ }
32166
+ const arg = { kind: "file", flag, contents: contentBase64, encoding: "base64", filename };
32167
+ if (opts.positional) arg.positional = true;
32168
+ return { arg, bytes };
32169
+ }
32170
+
32082
32171
  // ../gogcli-mcp/src/tools/sheets.ts
32083
32172
  var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
32084
32173
  var dryRunParam = external_exports.boolean().optional().describe(
@@ -32089,7 +32178,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
32089
32178
  );
32090
32179
 
32091
32180
  // ../gogcli-mcp/src/server.ts
32092
- var VERSION = true ? "2.23.2" : "0.0.0";
32181
+ var VERSION = true ? "2.25.0" : "0.0.0";
32093
32182
 
32094
32183
  // ../gogcli-mcp/src/auth-log.ts
32095
32184
  var FAILURES = /* @__PURE__ */ new Set([
@@ -32595,10 +32684,11 @@ function registerExtraDriveTools(server) {
32595
32684
  return runOrDiagnose(args, { account });
32596
32685
  });
32597
32686
  server.registerTool("gog_drive_upload", {
32598
- description: 'Upload a local file to Drive. Use replace to replace the content of an existing file (preserves link/permissions), or convert to auto-convert to a Google format. Pair replace with ifVersion to make the overwrite conditional: gog sends an atomic If-Match precondition and reports a conflict \u2014 applying nothing \u2014 if the file changed since you read it, instead of silently clobbering a concurrent edit. No read tool surfaces the version number: fetch it immediately before uploading with gog_drive_run { subcommand: "raw", args: ["<fileId>", "--fields=version"] } \u2014 it comes back as a JSON string, so pass it on as a number. Conditional replacement refuses Google Workspace files (Docs/Sheets/Slides), which have no replaceable binary content.',
32687
+ description: `Upload a file to Drive. Supply it EITHER as localPath (a path on the gog server's own filesystem \u2014 local stdio deployments only) OR as content (the bytes, base64-encoded, carried with the request), which is what works when this server runs remotely from you and no path you can name exists on it. Use replace to replace the content of an existing file (preserves link/permissions), or convert to auto-convert to a Google format. Pair replace with ifVersion to make the overwrite conditional: gog sends an atomic If-Match precondition and reports a conflict \u2014 applying nothing \u2014 if the file changed since you read it, instead of silently clobbering a concurrent edit. No read tool surfaces the version number: fetch it immediately before uploading with gog_drive_run { subcommand: "raw", args: ["<fileId>", "--fields=version"] } \u2014 it comes back as a JSON string, so pass it on as a number. Conditional replacement refuses Google Workspace files (Docs/Sheets/Slides), which have no replaceable binary content.`,
32599
32688
  annotations: { destructiveHint: true },
32600
32689
  inputSchema: {
32601
- localPath: external_exports.string().describe("Path to the local file to upload"),
32690
+ localPath: external_exports.string().optional().describe(`Path to the file to upload, resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend this path does not exist and the call fails with "no such file or directory" \u2014 use content there. Exactly one of localPath / content is required.`),
32691
+ content: external_exports.string().optional().describe("The file's bytes, base64-encoded (standard alphabet, with padding) \u2014 upload a file you hold without it existing anywhere on the gog server. This is the only route that works when the caller and gog share no filesystem. Requires name (there is no path to take a filename from). Max 8 MiB; for anything larger use localPath from a local deployment. Exactly one of localPath / content is required \u2014 supplying both is an error, not a precedence rule."),
32602
32692
  name: external_exports.string().optional().describe("Override filename (create) or rename target (replace)"),
32603
32693
  parent: external_exports.string().optional().describe("Destination folder ID (create only)"),
32604
32694
  replace: external_exports.string().optional().describe("Replace content of an existing Drive file ID (preserves link/permissions). Unconditional unless ifVersion is set."),
@@ -32611,11 +32701,29 @@ function registerExtraDriveTools(server) {
32611
32701
  convertTo: external_exports.string().optional().describe("Convert to a specific Google format: doc | sheet | slides (create only)"),
32612
32702
  account: accountParam
32613
32703
  }
32614
- }, async ({ localPath, name, parent, replace, ifVersion, mimeType, keepRevisionForever, convert, convertTo, account }) => {
32704
+ }, async ({ localPath, content, name, parent, replace, ifVersion, mimeType, keepRevisionForever, convert, convertTo, account }) => {
32615
32705
  if (ifVersion !== void 0 && !replace) {
32616
32706
  throw new Error("ifVersion requires replace: a version precondition only applies when replacing an existing Drive file.");
32617
32707
  }
32618
- const args = ["drive", "upload", localPath];
32708
+ if (localPath === void 0 === (content === void 0)) {
32709
+ throw new Error(
32710
+ "Pass exactly one of localPath or content. localPath reads a file on the GOG SERVER's filesystem (local stdio deployments only); content carries the bytes with the request, base64-encoded, and is what works when this server runs remotely from you."
32711
+ );
32712
+ }
32713
+ let pathArg;
32714
+ if (localPath !== void 0) {
32715
+ pathArg = localPath;
32716
+ } else {
32717
+ if (!name) {
32718
+ throw new Error("content requires name: there is no path to derive the Drive filename from.");
32719
+ }
32720
+ pathArg = inlineFileArg(
32721
+ "localPath",
32722
+ { filename: name, contentBase64: content },
32723
+ { positional: true, where: "content" }
32724
+ ).arg;
32725
+ }
32726
+ const args = ["drive", "upload", pathArg];
32619
32727
  if (name) args.push(`--name=${name}`);
32620
32728
  if (parent) args.push(`--parent=${parent}`);
32621
32729
  if (replace) args.push(`--replace=${replace}`);
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-drive",
5
5
  "display_name": "gogcli (Drive)",
6
- "version": "2.23.2",
6
+ "version": "2.25.0",
7
7
  "description": "Extended Google Drive for Claude via gogcli — auth + full Drive support (upload, download, permissions, comments, shared drives)",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -139,7 +139,7 @@
139
139
  },
140
140
  {
141
141
  "name": "gog_drive_upload",
142
- "description": "Upload a local file, optionally replacing an existing file (unconditionally, or only at a given version) or converting to Google format"
142
+ "description": "Upload a file to Drive from a server-side path (localPath) or from base64 bytes sent with the call (content, for remote deployments with no shared filesystem), optionally replacing an existing file (unconditionally, or only at a given version) or converting to Google format"
143
143
  },
144
144
  {
145
145
  "name": "gog_drive_sync_push",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-drive",
3
- "version": "2.23.2",
3
+ "version": "2.25.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-drive",
5
5
  "description": "Extended Google Drive MCP server via gogcli — auth + full Drive support (upload/download/permissions/comments/shared drives)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp-drive"
9
9
  },
10
- "version": "2.23.2",
10
+ "version": "2.25.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-drive",
15
- "version": "2.23.2",
15
+ "version": "2.25.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, paginationParams, pushPaginationFlags, pageTokenParam, pageAliasParam, resolvePageToken} from '../../../gogcli-mcp/src/lib.js';
3
+ import { accountParam, runOrDiagnose, paginationParams, pushPaginationFlags, pageTokenParam, pageAliasParam, resolvePageToken, inlineFileArg} from '../../../gogcli-mcp/src/lib.js';
4
+ import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
4
5
 
5
6
  export function registerExtraDriveTools(server: McpServer): void {
6
7
  server.registerTool('gog_drive_download', {
@@ -23,7 +24,10 @@ export function registerExtraDriveTools(server: McpServer): void {
23
24
 
24
25
  server.registerTool('gog_drive_upload', {
25
26
  description:
26
- 'Upload a local file to Drive. Use replace to replace the content of an existing file (preserves link/permissions), ' +
27
+ 'Upload a file to Drive. Supply it EITHER as localPath (a path on the gog server\'s own filesystem local ' +
28
+ 'stdio deployments only) OR as content (the bytes, base64-encoded, carried with the request), which is what ' +
29
+ 'works when this server runs remotely from you and no path you can name exists on it. ' +
30
+ 'Use replace to replace the content of an existing file (preserves link/permissions), ' +
27
31
  'or convert to auto-convert to a Google format. ' +
28
32
  'Pair replace with ifVersion to make the overwrite conditional: gog sends an atomic If-Match precondition and reports ' +
29
33
  'a conflict — applying nothing — if the file changed since you read it, instead of silently clobbering a concurrent edit. ' +
@@ -32,7 +36,8 @@ export function registerExtraDriveTools(server: McpServer): void {
32
36
  'Conditional replacement refuses Google Workspace files (Docs/Sheets/Slides), which have no replaceable binary content.',
33
37
  annotations: { destructiveHint: true },
34
38
  inputSchema: {
35
- localPath: z.string().describe('Path to the local file to upload'),
39
+ localPath: z.string().optional().describe('Path to the file to upload, resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend this path does not exist and the call fails with "no such file or directory" — use content there. Exactly one of localPath / content is required.'),
40
+ content: z.string().optional().describe('The file\'s bytes, base64-encoded (standard alphabet, with padding) — upload a file you hold without it existing anywhere on the gog server. This is the only route that works when the caller and gog share no filesystem. Requires name (there is no path to take a filename from). Max 8 MiB; for anything larger use localPath from a local deployment. Exactly one of localPath / content is required — supplying both is an error, not a precedence rule.'),
36
41
  name: z.string().optional().describe('Override filename (create) or rename target (replace)'),
37
42
  parent: z.string().optional().describe('Destination folder ID (create only)'),
38
43
  replace: z.string().optional().describe('Replace content of an existing Drive file ID (preserves link/permissions). Unconditional unless ifVersion is set.'),
@@ -47,11 +52,37 @@ export function registerExtraDriveTools(server: McpServer): void {
47
52
  convertTo: z.string().optional().describe('Convert to a specific Google format: doc | sheet | slides (create only)'),
48
53
  account: accountParam,
49
54
  },
50
- }, async ({ localPath, name, parent, replace, ifVersion, mimeType, keepRevisionForever, convert, convertTo, account }) => {
55
+ }, async ({ localPath, content, name, parent, replace, ifVersion, mimeType, keepRevisionForever, convert, convertTo, account }) => {
51
56
  if (ifVersion !== undefined && !replace) {
52
57
  throw new Error('ifVersion requires replace: a version precondition only applies when replacing an existing Drive file.');
53
58
  }
54
- const args = ['drive', 'upload', localPath];
59
+ // Explicit either/or, never a precedence rule: a caller who sends both has
60
+ // two different files in mind and silently picking one would upload the
61
+ // wrong bytes under the right name.
62
+ if ((localPath === undefined) === (content === undefined)) {
63
+ throw new Error(
64
+ 'Pass exactly one of localPath or content. localPath reads a file on the GOG SERVER\'s filesystem '
65
+ + '(local stdio deployments only); content carries the bytes with the request, base64-encoded, and '
66
+ + 'is what works when this server runs remotely from you.',
67
+ );
68
+ }
69
+ // The positional <localPath> argument: either the caller's real server-side
70
+ // path, or a temp file the executor materializes next to gog from the bytes
71
+ // they sent. gog cannot tell the two apart, which is the point.
72
+ let pathArg: GogArg;
73
+ if (localPath !== undefined) {
74
+ pathArg = localPath;
75
+ } else {
76
+ if (!name) {
77
+ throw new Error('content requires name: there is no path to derive the Drive filename from.');
78
+ }
79
+ pathArg = inlineFileArg(
80
+ 'localPath',
81
+ { filename: name, contentBase64: content as string },
82
+ { positional: true, where: 'content' },
83
+ ).arg;
84
+ }
85
+ const args: GogArg[] = ['drive', 'upload', pathArg];
55
86
  if (name) args.push(`--name=${name}`);
56
87
  if (parent) args.push(`--parent=${parent}`);
57
88
  if (replace) args.push(`--replace=${replace}`);
@@ -104,6 +104,85 @@ describe('gog_drive_upload', () => {
104
104
  expect(lib.runOrDiagnose).not.toHaveBeenCalled();
105
105
  });
106
106
 
107
+ // ==========================================================================
108
+ // INLINE CONTENT — uploading a file the caller holds but the gog server does
109
+ // not, which is every remote deployment. `content` replaces the positional
110
+ // localPath with a temp file the executor materializes beside gog.
111
+ // ==========================================================================
112
+ describe('content (inline bytes)', () => {
113
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64');
114
+
115
+ it('materializes content as the POSITIONAL path argument', async () => {
116
+ await harness.callTool('gog_drive_upload', { content: PNG, name: 'pendant-layouts.png' });
117
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
118
+ [
119
+ 'drive', 'upload',
120
+ { kind: 'file', flag: 'localPath', contents: PNG, encoding: 'base64', filename: 'pendant-layouts.png', positional: true },
121
+ '--name=pendant-layouts.png',
122
+ ],
123
+ { account: undefined },
124
+ );
125
+ });
126
+
127
+ it('carries the caller mimeType through, which gog CAN honour here', async () => {
128
+ await harness.callTool('gog_drive_upload', { content: PNG, name: 'a.png', mimeType: 'image/png' });
129
+ const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0];
130
+ expect(args).toContain('--mime-type=image/png');
131
+ });
132
+
133
+ it('rejects supplying BOTH localPath and content — never a precedence rule', async () => {
134
+ const result = await harness.callTool('gog_drive_upload', { localPath: '/tmp/x.png', content: PNG, name: 'x.png' });
135
+ expect(result.isError).toBe(true);
136
+ expect(result.content[0].text).toMatch(/Pass exactly one of localPath or content/);
137
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it('rejects supplying NEITHER localPath nor content', async () => {
141
+ const result = await harness.callTool('gog_drive_upload', { name: 'x.png' });
142
+ expect(result.isError).toBe(true);
143
+ expect(result.content[0].text).toMatch(/Pass exactly one of localPath or content/);
144
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('requires name with content, since there is no path to derive it from', async () => {
148
+ const result = await harness.callTool('gog_drive_upload', { content: PNG });
149
+ expect(result.isError).toBe(true);
150
+ expect(result.content[0].text).toMatch(/content requires name/);
151
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
152
+ });
153
+
154
+ it('rejects invalid base64 rather than uploading a corrupt file', async () => {
155
+ const result = await harness.callTool('gog_drive_upload', { content: 'not!base64!', name: 'a.png' });
156
+ expect(result.isError).toBe(true);
157
+ expect(result.content[0].text).toMatch(/not valid base64/);
158
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('rejects content over the 8 MiB ceiling with a message naming the limit', async () => {
162
+ const result = await harness.callTool('gog_drive_upload', {
163
+ content: Buffer.alloc(8 * 1024 * 1024 + 1).toString('base64'),
164
+ name: 'huge.bin',
165
+ });
166
+ expect(result.isError).toBe(true);
167
+ expect(result.content[0].text).toMatch(/per-file limit/);
168
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('keeps a spaced, non-ASCII name intact as the Drive filename', async () => {
172
+ await harness.callTool('gog_drive_upload', { content: PNG, name: 'Reçu — étude 2026.png' });
173
+ const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0];
174
+ expect(args[2]).toMatchObject({ filename: 'Reçu — étude 2026.png' });
175
+ expect(args).toContain('--name=Reçu — étude 2026.png');
176
+ });
177
+
178
+ // The existing path-based contract must be untouched: a plain string
179
+ // positional, no file arg, for every local stdio caller that has one.
180
+ it('leaves the localPath path unchanged', async () => {
181
+ await harness.callTool('gog_drive_upload', { localPath: '/tmp/x.txt' });
182
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['drive', 'upload', '/tmp/x.txt'], { account: undefined });
183
+ });
184
+ });
185
+
107
186
  // No gog read command surfaces the Drive `version` int: driveFileGetFields
108
187
  // and info_via_drive.go both omit it, and `drive upload` itself is the only
109
188
  // caller that requests it. `drive raw` (fields=*) is the sole way to read it,