gogcli-mcp-docs 4.9.1 → 4.10.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 Docs for Claude via gogcli — auth + full Docs and comments support",
10
- "version": "4.9.1"
10
+ "version": "4.10.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli (Docs)",
16
16
  "source": "./",
17
17
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
18
- "version": "4.9.1",
18
+ "version": "4.10.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
3
  "displayName": "gogcli (Docs)",
4
- "version": "4.9.1",
4
+ "version": "4.10.0",
5
5
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -79,6 +79,14 @@ These bugs live in gogcli's upstream markdown → Docs converter; the wrapper ju
79
79
  | Inline `**bold**` / `*italic*` / `` `code` `` inside table cells renders as literal characters | Insert plain cell text, then apply formatting via `gog_docs_format` | [openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608) |
80
80
  | Tables with an empty header row leak the last data row as literal pipe text | Always supply a non-empty header row | [openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609) |
81
81
 
82
+ ## Files on a hosted deployment
83
+
84
+ When this server is hosted (remote), it cannot read paths on your machine. Call
85
+ `gog_upload_slots` with `files: [{filename}]`, PUT each file to its `uploadUrl`
86
+ with the slot's `curl` command, then pass the returned `blobKeys` to `gog_docs_insert_image` and `gog_docs_replace_image` (instead of `file`).
87
+ Files up to 25 MiB; each is deleted from the upload store after the call succeeds
88
+ and kept if it fails. On a local install, pass a path as before.
89
+
82
90
  ## License
83
91
 
84
92
  MIT
package/SKILL.md CHANGED
@@ -62,3 +62,11 @@ Three bugs in gogcli's upstream markdown→Docs converter to be aware of:
62
62
  - **3+ tables in one call** reorders the trailing punctuation of the paragraph before the 3rd table — split into multiple calls with ≤2 tables each ([openclaw/gogcli#607](https://github.com/openclaw/gogcli/issues/607)).
63
63
  - **Inline `**bold**` / `*italic*` / `` `code` `` inside table cells** renders as literal characters — insert plain cell text, then apply formatting via `gog_docs_format` ([openclaw/gogcli#608](https://github.com/openclaw/gogcli/issues/608)).
64
64
  - **Empty-header tables** leak the last data row as literal pipe text — always supply a non-empty header row ([openclaw/gogcli#609](https://github.com/openclaw/gogcli/issues/609)).
65
+
66
+ ## Files on a hosted deployment
67
+
68
+ When this server is hosted (remote), it cannot read paths on your machine. Call
69
+ `gog_upload_slots` with `files: [{filename}]`, PUT each file to its `uploadUrl`
70
+ with the slot's `curl` command, then pass the returned `blobKeys` to `gog_docs_insert_image` and `gog_docs_replace_image` (instead of `file`).
71
+ Files up to 25 MiB; each is deleted from the upload store after the call succeeds
72
+ and kept if it fails. On a local install, pass a path as before.
package/dist/index.js CHANGED
@@ -14,6 +14,14 @@ function withCallSignal(signal, fn, request) {
14
14
  function currentCallSignal() {
15
15
  return storage.getStore()?.signal;
16
16
  }
17
+ function withAmbientCancellation(own2) {
18
+ const ambient = currentCallSignal();
19
+ if (!ambient)
20
+ return own2;
21
+ if (!own2)
22
+ return ambient;
23
+ return AbortSignal.any([own2, ambient]);
24
+ }
17
25
  function killOnCancel(child, signal = "SIGTERM") {
18
26
  const ambient = currentCallSignal();
19
27
  if (!ambient)
@@ -36309,9 +36317,158 @@ import { constants as fsConstants2, realpathSync as realpathSync2 } from "node:f
36309
36317
  var O_NOFOLLOW2 = fsConstants2.O_NOFOLLOW ?? 0;
36310
36318
  var O_NONBLOCK = fsConstants2.O_NONBLOCK ?? 0;
36311
36319
 
36320
+ // ../../node_modules/@chrischall/mcp-utils/dist/internal/headers.js
36321
+ function responseHeader(res, name) {
36322
+ return getHeader(res?.headers, name);
36323
+ }
36324
+ function getHeader(headers, name) {
36325
+ if (!headers || typeof headers.get !== "function")
36326
+ return null;
36327
+ const value = headers.get.call(headers, name);
36328
+ return typeof value === "string" ? value : null;
36329
+ }
36330
+
36312
36331
  // ../../node_modules/@chrischall/mcp-utils/dist/scrape/index.js
36313
36332
  var NEXT_DATA_MAX_CHARS = 16 * 1024 * 1024;
36314
36333
 
36334
+ // ../../node_modules/@chrischall/mcp-utils/dist/http/body-limit.js
36335
+ var ResponseTooLargeError = class extends McpToolError {
36336
+ maxBytes;
36337
+ constructor(service, maxBytes) {
36338
+ super(`Response from ${service} is larger than the ${maxBytes}-byte limit.`, {
36339
+ hint: "Raise maxBytes (or the client's maxResponseBytes) if a response this large is expected.",
36340
+ kind: "too_large"
36341
+ });
36342
+ this.name = "ResponseTooLargeError";
36343
+ this.maxBytes = maxBytes;
36344
+ }
36345
+ };
36346
+ async function readBytesCapped(res, maxBytes, service, bounded) {
36347
+ if (maxBytes !== void 0) {
36348
+ const declared = Number(responseHeader(res, "content-length") ?? NaN);
36349
+ if (Number.isFinite(declared) && declared > maxBytes) {
36350
+ cancelStream(res.body);
36351
+ throw new ResponseTooLargeError(service, maxBytes);
36352
+ }
36353
+ }
36354
+ if (!res.body)
36355
+ return new Uint8Array(0);
36356
+ const reader = res.body.getReader();
36357
+ const chunks = [];
36358
+ let total = 0;
36359
+ try {
36360
+ for (; ; ) {
36361
+ const { done, value } = await bounded(reader.read());
36362
+ if (done)
36363
+ break;
36364
+ total += value.byteLength;
36365
+ if (maxBytes !== void 0 && total > maxBytes)
36366
+ throw new ResponseTooLargeError(service, maxBytes);
36367
+ chunks.push(value);
36368
+ }
36369
+ } catch (err) {
36370
+ reader.cancel().catch(() => {
36371
+ });
36372
+ throw err;
36373
+ }
36374
+ const out = new Uint8Array(total);
36375
+ let offset = 0;
36376
+ for (const c of chunks) {
36377
+ out.set(c, offset);
36378
+ offset += c.byteLength;
36379
+ }
36380
+ return out;
36381
+ }
36382
+ function cancelStream(body) {
36383
+ try {
36384
+ body?.cancel().catch(() => {
36385
+ });
36386
+ } catch {
36387
+ }
36388
+ }
36389
+
36390
+ // ../../node_modules/@chrischall/mcp-utils/dist/http/fetch-bounded.js
36391
+ async function fetchBounded(url2, init, opts = {}) {
36392
+ const doFetch = opts.fetchImpl ?? fetch;
36393
+ const service = opts.service ?? hostOf(url2);
36394
+ const read = opts.read ?? "text";
36395
+ const timeoutMs = opts.timeoutMs === false ? 0 : opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
36396
+ const caller = withAmbientCancellation(init?.signal ?? void 0);
36397
+ if (caller?.aborted)
36398
+ throw reasonOf(caller);
36399
+ const timer = timeoutMs > 0 ? new AbortController() : void 0;
36400
+ const signal = combine(timer?.signal, caller);
36401
+ let handle;
36402
+ let detach = () => {
36403
+ };
36404
+ const stop = signal ? new Promise((_resolve, reject) => {
36405
+ const onAbort = () => reject(timer?.signal.aborted ? new RequestTimeoutError(service, timeoutMs) : reasonOf(caller));
36406
+ signal.addEventListener("abort", onAbort, { once: true });
36407
+ detach = () => signal.removeEventListener("abort", onAbort);
36408
+ }) : void 0;
36409
+ stop?.catch(() => {
36410
+ });
36411
+ const bounded = (p) => stop ? Promise.race([p, stop]) : p;
36412
+ if (timer)
36413
+ handle = setTimeout(() => timer.abort(), timeoutMs);
36414
+ try {
36415
+ let res;
36416
+ try {
36417
+ res = await bounded(doFetch(url2, { ...init, ...signal ? { signal } : {} }));
36418
+ } catch (err) {
36419
+ if (timer?.signal.aborted)
36420
+ throw new RequestTimeoutError(service, timeoutMs);
36421
+ if (caller?.aborted)
36422
+ throw reasonOf(caller);
36423
+ throw err;
36424
+ }
36425
+ if (read === "none") {
36426
+ cancelStream(res.body);
36427
+ return done(res, void 0);
36428
+ }
36429
+ const bytes = await readBytesCapped(res, opts.maxBytes, service, bounded);
36430
+ if (read === "bytes")
36431
+ return done(res, bytes);
36432
+ const text = new TextDecoder().decode(bytes);
36433
+ if (read === "text")
36434
+ return done(res, text);
36435
+ if (text.length === 0)
36436
+ return done(res, void 0);
36437
+ try {
36438
+ return done(res, JSON.parse(text));
36439
+ } catch (cause) {
36440
+ const type = res.headers.get("content-type") ?? "no content-type";
36441
+ throw new McpToolError(`${service} returned a non-JSON response (HTTP ${res.status}, ${type}).`, {
36442
+ hint: "The upstream may have served an error or sign-in page instead of data.",
36443
+ cause,
36444
+ status: res.status
36445
+ });
36446
+ }
36447
+ } finally {
36448
+ clearTimeout(handle);
36449
+ detach();
36450
+ }
36451
+ function done(res, body) {
36452
+ return { res, status: res.status, ok: res.ok, headers: res.headers, body };
36453
+ }
36454
+ }
36455
+ function combine(a, b) {
36456
+ if (a && b)
36457
+ return AbortSignal.any([a, b]);
36458
+ return a ?? b;
36459
+ }
36460
+ function reasonOf(signal) {
36461
+ const reason = signal.reason;
36462
+ return reason instanceof Error ? reason : new Error(String(reason));
36463
+ }
36464
+ function hostOf(url2) {
36465
+ try {
36466
+ return new URL(url2).host;
36467
+ } catch {
36468
+ return String(url2);
36469
+ }
36470
+ }
36471
+
36315
36472
  // ../../node_modules/@chrischall/mcp-utils/dist/netguard/address.js
36316
36473
  function parseIPv4(text) {
36317
36474
  if (!/^(?:0|[1-9]\d{0,2})(?:\.(?:0|[1-9]\d{0,2})){3}$/.test(text))
@@ -36395,6 +36552,18 @@ var NAT64 = V6("64:ff9b::");
36395
36552
  var SIX_TO_FOUR = V6("2002::");
36396
36553
 
36397
36554
  // ../../node_modules/@chrischall/mcp-utils/dist/http/index.js
36555
+ var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
36556
+ var RequestTimeoutError = class extends Error {
36557
+ timeoutMs;
36558
+ /** Always `'timeout'` unless a subclass says otherwise. */
36559
+ kind = "timeout";
36560
+ constructor(service, timeoutMs) {
36561
+ super(`Request to ${service} timed out after ${timeoutMs}ms.`);
36562
+ this.name = "RequestTimeoutError";
36563
+ this.timeoutMs = timeoutMs;
36564
+ Object.setPrototypeOf(this, new.target.prototype);
36565
+ }
36566
+ };
36398
36567
  var EDGE_BLOCK_SCAN_MAX = 8 * 1024;
36399
36568
 
36400
36569
  // ../../node_modules/@chrischall/mcp-utils/dist/zod/index.js
@@ -37508,6 +37677,445 @@ var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional(
37508
37677
  `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 a hosted deployment (e.g. mcp-host). 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.`
37509
37678
  );
37510
37679
 
37680
+ // ../gogcli-mcp/src/upload-slots.ts
37681
+ import { createHash as createHash4 } from "node:crypto";
37682
+ import { existsSync as existsSync2 } from "node:fs";
37683
+
37684
+ // ../../node_modules/@chrischall/mcp-utils/dist/export/blob.js
37685
+ import { createHmac as createHmac3 } from "node:crypto";
37686
+ var BLOB_DEFAULT_TTL_MS = 60 * 60 * 1e3;
37687
+ var BLOB_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
37688
+ var BLOB_MAX_OBJECT_BYTES = 100 * 1024 * 1024;
37689
+ var MAX_KEY_LENGTH = 1024;
37690
+ var SAFE_SEGMENT = /^[A-Za-z0-9._~-]+$/;
37691
+ function configError(reason) {
37692
+ return new McpToolError(`MCP_BLOB_BASE_URL is not usable: ${reason}.`, {
37693
+ hint: "It should look like https://<host>/b/<registrationId> \u2014 check the deployment's environment.",
37694
+ kind: "unknown"
37695
+ });
37696
+ }
37697
+ function parseBase(baseUrl) {
37698
+ let parsed;
37699
+ try {
37700
+ parsed = new URL(baseUrl);
37701
+ } catch {
37702
+ throw configError("not a valid URL");
37703
+ }
37704
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
37705
+ throw configError("scheme must be http(s)");
37706
+ if (parsed.username || parsed.password)
37707
+ throw configError("must not contain credentials");
37708
+ if (parsed.search || parsed.hash)
37709
+ throw configError("must not contain a query or fragment");
37710
+ const path = parsed.pathname.replace(/^\/+|\/+$/g, "");
37711
+ const prefix = path.startsWith("b/") ? path.slice(2) : path;
37712
+ if (!prefix || prefix === "b")
37713
+ throw configError("has no registration path");
37714
+ return { base: `${parsed.origin}/${path}`, prefix };
37715
+ }
37716
+ function badInput(message) {
37717
+ return new McpToolError(message, { kind: "unknown" });
37718
+ }
37719
+ function encodeKey(key) {
37720
+ if (typeof key !== "string" || key.length === 0)
37721
+ throw badInput("Blob key must be a non-empty string.");
37722
+ if (key.length > MAX_KEY_LENGTH)
37723
+ throw badInput(`Blob key is longer than ${MAX_KEY_LENGTH} characters.`);
37724
+ const segments = key.split("/");
37725
+ for (const seg of segments) {
37726
+ if (seg === "" || seg === "." || seg === ".." || !SAFE_SEGMENT.test(seg)) {
37727
+ throw badInput('Blob key must be relative, slash-separated segments of [A-Za-z0-9._~-] with no empty, "." or ".." segment.');
37728
+ }
37729
+ }
37730
+ return segments.map(encodeURIComponent).join("/");
37731
+ }
37732
+ function checkContentType(contentType) {
37733
+ if (typeof contentType !== "string" || contentType.length === 0 || /[\x00-\x1f\x7f]/.test(contentType)) {
37734
+ throw badInput("Blob content type must be a non-empty string without control characters.");
37735
+ }
37736
+ }
37737
+ function expiry(opts) {
37738
+ const ttl = opts?.ttlMs ?? BLOB_DEFAULT_TTL_MS;
37739
+ if (!Number.isFinite(ttl) || ttl <= 0 || ttl > BLOB_MAX_TTL_MS) {
37740
+ throw badInput(`Blob link TTL must be between 1 ms and ${BLOB_MAX_TTL_MS} ms (24h); the gateway refuses longer.`);
37741
+ }
37742
+ return Math.floor((opts?.now ?? Date.now()) + ttl);
37743
+ }
37744
+ function toArrayBuffer(view) {
37745
+ const buf = view.buffer;
37746
+ if (buf instanceof ArrayBuffer && view.byteOffset === 0 && view.byteLength === buf.byteLength)
37747
+ return buf;
37748
+ return view.slice().buffer;
37749
+ }
37750
+ function createBlobStore(opts) {
37751
+ const { base, prefix } = parseBase(opts.baseUrl);
37752
+ const signingKey = opts.signingKey;
37753
+ const sign2 = (payload) => createHmac3("sha256", signingKey).update(payload).digest("base64url");
37754
+ const link = (encodedKey, exp, sig) => `${base}/${encodedKey}?exp=${exp}&sig=${encodeURIComponent(sig)}`;
37755
+ const signGetUrl = (key, o) => {
37756
+ const encoded = encodeKey(key);
37757
+ const exp = expiry(o);
37758
+ return { url: link(encoded, exp, sign2(`${prefix}/${key}
37759
+ ${exp}`)), expiresAtMs: exp };
37760
+ };
37761
+ const signPutUrl = (key, contentType, o) => {
37762
+ const encoded = encodeKey(key);
37763
+ checkContentType(contentType);
37764
+ const exp = expiry(o);
37765
+ return { url: link(encoded, exp, sign2(`put\0${prefix}/${key}\0${contentType}\0${exp}`)), expiresAtMs: exp };
37766
+ };
37767
+ const put = async (key, body, contentType, o = {}) => {
37768
+ const { url: url2 } = signPutUrl(key, contentType, o);
37769
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
37770
+ const doFetch = o.fetchImpl ?? fetch;
37771
+ let res;
37772
+ try {
37773
+ res = await doFetch(url2, {
37774
+ method: "PUT",
37775
+ headers: { "content-type": contentType },
37776
+ body: toArrayBuffer(bytes),
37777
+ ...o.signal ? { signal: o.signal } : {}
37778
+ });
37779
+ } catch (cause) {
37780
+ if (o.signal?.aborted)
37781
+ throw cause;
37782
+ throw new McpToolError(`Blob store upload of ${key} did not complete (network error).`, {
37783
+ kind: "transport"
37784
+ });
37785
+ }
37786
+ if (!res.ok) {
37787
+ throw new McpToolError(`Blob store upload of ${key} failed (HTTP ${res.status}).`, {
37788
+ kind: "http",
37789
+ status: res.status
37790
+ });
37791
+ }
37792
+ return { key, bytes: bytes.byteLength };
37793
+ };
37794
+ const signDeleteUrl = (key, o) => {
37795
+ const encoded = encodeKey(key);
37796
+ const exp = expiry(o);
37797
+ return { url: link(encoded, exp, sign2(`del\0${prefix}/${key}\0${exp}`)), expiresAtMs: exp };
37798
+ };
37799
+ const exchange = async (what, url2, method, read, o) => {
37800
+ try {
37801
+ return await fetchBounded(url2, { method, ...o.signal ? { signal: o.signal } : {} }, {
37802
+ read,
37803
+ service: "the blob store",
37804
+ ...read === "bytes" ? { maxBytes: o.maxBytes ?? BLOB_MAX_OBJECT_BYTES } : {},
37805
+ ...o.timeoutMs !== void 0 ? { timeoutMs: o.timeoutMs } : {},
37806
+ ...o.fetchImpl ? { fetchImpl: o.fetchImpl } : {}
37807
+ });
37808
+ } catch (cause) {
37809
+ if (o.signal?.aborted || cause instanceof McpToolError || cause instanceof RequestTimeoutError)
37810
+ throw cause;
37811
+ throw new McpToolError(`Blob store ${what} did not complete (network error).`, { kind: "transport" });
37812
+ }
37813
+ };
37814
+ const get = async (key, o = {}) => {
37815
+ const { url: url2 } = signGetUrl(key, o);
37816
+ const res = await exchange(`read of ${key}`, url2, "GET", "bytes", o);
37817
+ if (!res.ok) {
37818
+ const why = res.status === 404 ? " \u2014 not found (never uploaded, expired, or already consumed)" : "";
37819
+ throw new McpToolError(`Blob store read of ${key} failed (HTTP ${res.status})${why}.`, {
37820
+ kind: "http",
37821
+ status: res.status
37822
+ });
37823
+ }
37824
+ const contentType = res.headers.get("content-type") ?? void 0;
37825
+ return { key, bytes: res.body, ...contentType !== void 0 ? { contentType } : {} };
37826
+ };
37827
+ const del = async (key, o = {}) => {
37828
+ const { url: url2 } = signDeleteUrl(key, o);
37829
+ const res = await exchange(`delete of ${key}`, url2, "DELETE", "none", o);
37830
+ if (!res.ok) {
37831
+ throw new McpToolError(`Blob store delete of ${key} failed (HTTP ${res.status}).`, {
37832
+ kind: "http",
37833
+ status: res.status
37834
+ });
37835
+ }
37836
+ };
37837
+ const consume = async (key, use, o = {}) => {
37838
+ const object2 = await get(key, o);
37839
+ const result = await use(object2);
37840
+ try {
37841
+ await del(key, o);
37842
+ return { result, deleted: true };
37843
+ } catch (e) {
37844
+ return { result, deleted: false, deleteError: e instanceof Error ? e.message : String(e) };
37845
+ }
37846
+ };
37847
+ return { baseUrl: base, prefix, signGetUrl, signPutUrl, put, signDeleteUrl, get, delete: del, consume };
37848
+ }
37849
+ function blobStoreFromEnv(env = process.env) {
37850
+ const baseUrl = readEnvVar("MCP_BLOB_BASE_URL", { env });
37851
+ const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY", { env });
37852
+ if (!baseUrl || !signingKey)
37853
+ return void 0;
37854
+ return createBlobStore({ baseUrl, signingKey });
37855
+ }
37856
+
37857
+ // ../../node_modules/@chrischall/mcp-utils/dist/export/zip.js
37858
+ var CRC_TABLE = (() => {
37859
+ const table = new Uint32Array(256);
37860
+ for (let n = 0; n < 256; n++) {
37861
+ let c = n;
37862
+ for (let k = 0; k < 8; k++)
37863
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
37864
+ table[n] = c >>> 0;
37865
+ }
37866
+ return table;
37867
+ })();
37868
+
37869
+ // ../../node_modules/@chrischall/mcp-utils/dist/export/sink.js
37870
+ var EXPORT_BLOB_MAX_BYTES = 256 * 1024 * 1024;
37871
+
37872
+ // ../../node_modules/@chrischall/mcp-utils/dist/export/upload.js
37873
+ import { randomUUID } from "node:crypto";
37874
+ var MAX_UPLOAD_FILES = 10;
37875
+ var UPLOAD_KEY_PREFIX = "uploads";
37876
+ var UPLOAD_SLOT_DEFAULT_TTL_MS = 30 * 60 * 1e3;
37877
+ var MIB = 1024 * 1024;
37878
+ var OCTET_STREAM = "application/octet-stream";
37879
+ var inlineFileSchema = external_exports.object({
37880
+ filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot. Its extension sets the MIME type when mimeType is omitted."),
37881
+ contentBase64: external_exports.string().describe(`The file's bytes, standard base64 (line breaks and a "data:<type>;base64," prefix are tolerated).`),
37882
+ mimeType: external_exports.string().describe('MIME type, e.g. "image/jpeg". Default: inferred from the filename extension.').optional()
37883
+ });
37884
+ var uploadSlotRequestSchema = external_exports.object({
37885
+ filename: external_exports.string().describe("Filename the upstream will show \u2014 a bare name, no path, not starting with a dot."),
37886
+ mimeType: external_exports.string().describe("MIME type. Default: inferred from the filename extension. The PUT must send exactly this Content-Type.").optional()
37887
+ });
37888
+ function sanitizeUploadFilename(raw) {
37889
+ const name = raw.trim();
37890
+ if (name.length === 0)
37891
+ throw new Error("filename is empty.");
37892
+ if (/[/\\]/.test(name)) {
37893
+ throw new Error(`filename ${JSON.stringify(name)} must be a bare filename, not a path \u2014 pass just the name to show, e.g. "photo.jpg".`);
37894
+ }
37895
+ if (/[\x00-\x1f\x7f]/.test(name))
37896
+ throw new Error(`filename ${JSON.stringify(name)} contains control characters.`);
37897
+ if (name.startsWith(".")) {
37898
+ throw new Error(`filename ${JSON.stringify(name)} must not start with a dot (hidden files are never uploaded).`);
37899
+ }
37900
+ if (name.length > 200)
37901
+ throw new Error("filename is longer than 200 characters.");
37902
+ return name;
37903
+ }
37904
+ var MIME_BY_EXT = {
37905
+ pdf: "application/pdf",
37906
+ png: "image/png",
37907
+ jpg: "image/jpeg",
37908
+ jpeg: "image/jpeg",
37909
+ gif: "image/gif",
37910
+ webp: "image/webp",
37911
+ heic: "image/heic",
37912
+ heif: "image/heif",
37913
+ tif: "image/tiff",
37914
+ tiff: "image/tiff",
37915
+ svg: "image/svg+xml",
37916
+ txt: "text/plain",
37917
+ md: "text/markdown",
37918
+ csv: "text/csv",
37919
+ html: "text/html",
37920
+ htm: "text/html",
37921
+ json: "application/json",
37922
+ xml: "application/xml",
37923
+ ics: "text/calendar",
37924
+ doc: "application/msword",
37925
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
37926
+ xls: "application/vnd.ms-excel",
37927
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
37928
+ ppt: "application/vnd.ms-powerpoint",
37929
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
37930
+ zip: "application/zip",
37931
+ mp3: "audio/mpeg",
37932
+ m4a: "audio/mp4",
37933
+ wav: "audio/wav",
37934
+ mp4: "video/mp4",
37935
+ mov: "video/quicktime",
37936
+ webm: "video/webm"
37937
+ };
37938
+ function mimeFromFileName(name) {
37939
+ const dot = name.lastIndexOf(".");
37940
+ if (dot <= 0)
37941
+ return OCTET_STREAM;
37942
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? OCTET_STREAM;
37943
+ }
37944
+ var MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/;
37945
+ function resolveUploadMime(fileName, mimeType) {
37946
+ if (mimeType === void 0)
37947
+ return mimeFromFileName(fileName);
37948
+ const bare = (mimeType.split(";", 1)[0] ?? "").trim().toLowerCase();
37949
+ if (!MEDIA_TYPE.test(bare)) {
37950
+ throw new Error(`mimeType ${JSON.stringify(mimeType)} is not a type/subtype media type such as "image/jpeg".`);
37951
+ }
37952
+ return bare;
37953
+ }
37954
+ function limitText(maxBytes) {
37955
+ return `${Math.floor(maxBytes / MIB)} MiB (${maxBytes} bytes)`;
37956
+ }
37957
+ function checkUploadSize(fileName, size, maxBytes) {
37958
+ if (size === 0)
37959
+ throw new Error(`${fileName} is empty (0 bytes); nothing to upload.`);
37960
+ if (size > maxBytes)
37961
+ throw new Error(`${fileName} is too large (${size} bytes); the upload limit is ${limitText(maxBytes)}.`);
37962
+ }
37963
+ var PREFIX2 = /^[A-Za-z0-9._~-]+$/;
37964
+ function checkPrefix(prefix) {
37965
+ if (!PREFIX2.test(prefix) || prefix === "." || prefix === "..") {
37966
+ throw new Error(`Upload key prefix ${JSON.stringify(prefix)} must be one key segment of [A-Za-z0-9._~-].`);
37967
+ }
37968
+ }
37969
+ function mintUploadSlot(store, req, opts = {}) {
37970
+ const prefix = opts.prefix ?? UPLOAD_KEY_PREFIX;
37971
+ checkPrefix(prefix);
37972
+ const filename = sanitizeUploadFilename(req.filename);
37973
+ const mimeType = resolveUploadMime(filename, req.mimeType);
37974
+ const blobKey = `${prefix}/${randomUUID()}/${Buffer.from(filename, "utf8").toString("base64url")}`;
37975
+ const { url: url2, expiresAtMs } = store.signPutUrl(blobKey, mimeType, {
37976
+ ttlMs: opts.ttlMs ?? UPLOAD_SLOT_DEFAULT_TTL_MS,
37977
+ ...opts.now !== void 0 ? { now: opts.now } : {}
37978
+ });
37979
+ const shellName = filename.replace(/'/g, `'\\''`);
37980
+ return {
37981
+ filename,
37982
+ mimeType,
37983
+ blobKey,
37984
+ method: "PUT",
37985
+ uploadUrl: url2,
37986
+ expiresAtMs,
37987
+ expiresAt: new Date(expiresAtMs).toISOString(),
37988
+ // --fail turns a refused PUT (expired, wrong Content-Type) into a non-zero exit.
37989
+ curl: `curl -sS --fail -X PUT -H 'Content-Type: ${mimeType}' --data-binary @'<path to ${shellName}>' '${url2}'`
37990
+ };
37991
+ }
37992
+ var UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
37993
+ function uploadFileNameFromKey(key, opts = {}) {
37994
+ const prefix = opts.prefix ?? UPLOAD_KEY_PREFIX;
37995
+ checkPrefix(prefix);
37996
+ const escaped = prefix.replace(/[.~-]/g, "\\$&");
37997
+ const match = new RegExp(`^${escaped}/${UUID}/([A-Za-z0-9_-]+)$`).exec(key);
37998
+ if (!match) {
37999
+ throw new Error(`blobKey ${JSON.stringify(key)} is not an upload key. Pass the blobKey exactly as the upload slot returned it.`);
38000
+ }
38001
+ return sanitizeUploadFilename(Buffer.from(match[1], "base64url").toString("utf8"));
38002
+ }
38003
+
38004
+ // ../gogcli-mcp/src/blob-urls.ts
38005
+ var BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
38006
+ var BLOB_URL_CEILING_MARGIN_MS = 60 * 1e3;
38007
+ var BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1e3;
38008
+ function blobStoreFromEnv2() {
38009
+ const baseUrl = readEnvVar("MCP_BLOB_BASE_URL");
38010
+ const signingKey = readEnvVar("MCP_BLOB_SIGNING_KEY");
38011
+ if (!baseUrl || !signingKey) return void 0;
38012
+ return { baseUrl, signingKey };
38013
+ }
38014
+
38015
+ // ../gogcli-mcp/src/upload-slots.ts
38016
+ var UPLOAD_SLOTS_TOOL = "gog_upload_slots";
38017
+ var MAX_BLOB_FILE_BYTES = 25 * 1024 * 1024;
38018
+ var ATTACH_INLINE_ALTERNATIVE = `or, for a small file, pass its bytes base64-encoded in \`attachInline\` (${INLINE_ATTACHMENT_LIMITS_TEXT})`;
38019
+ function isHostedDeployment() {
38020
+ return readEnvVar("MCP_DATA_DIR") !== void 0 || blobStoreFromEnv2() !== void 0;
38021
+ }
38022
+ function requireUploadStore(what) {
38023
+ const store = blobStoreFromEnv();
38024
+ if (store) return store;
38025
+ throw new Error(
38026
+ `${what} needs the upload store a hosted (mcp-host) deployment provides, and this server has none \u2014 it is a local install, so gog can read your files directly. Pass a server path instead (attach / localPath / file / image, inside GOG_FILE_ROOTS), or for a small file the bytes with attachInline / content.`
38027
+ );
38028
+ }
38029
+ function blobFileName(key) {
38030
+ try {
38031
+ return uploadFileNameFromKey(key, { prefix: UPLOAD_KEY_PREFIX });
38032
+ } catch (e) {
38033
+ const message = e.message;
38034
+ if (!message.includes("is not an upload key")) throw e;
38035
+ throw new Error(`blobKey ${JSON.stringify(key)} is not an upload key. Pass each blobKey exactly as ${UPLOAD_SLOTS_TOOL} returned it.`);
38036
+ }
38037
+ }
38038
+ var blobKeysText = `Hosted deployments: blobKeys from ${UPLOAD_SLOTS_TOOL} for files you have ALREADY PUT to their uploadUrl. This is how to send a file you hold (a photo, a PDF) when this server is remote: no base64 in the call, up to ${MAX_BLOB_FILE_BYTES / (1024 * 1024)} MiB a file. Each file keeps the filename its slot was minted for. An uploaded file is deleted once this call succeeds and kept if it fails, so a retry can reuse the same keys.`;
38039
+ var blobKeysParam = external_exports.array(external_exports.string()).max(MAX_UPLOAD_FILES).optional().describe(
38040
+ `${blobKeysText} Up to ${MAX_UPLOAD_FILES}; combine freely with attach / attachInline.`
38041
+ );
38042
+ var singleBlobKeyParam = external_exports.array(external_exports.string()).max(1).optional().describe(
38043
+ `${blobKeysText} Exactly one key; use it INSTEAD of the path parameter.`
38044
+ );
38045
+ async function readBlobFiles(keys, flag, opts = {}) {
38046
+ if (!keys?.length) return [];
38047
+ if (new Set(keys).size !== keys.length) throw new Error("blobKeys lists the same blobKey twice; pass each uploaded file once.");
38048
+ const names = keys.map(blobFileName);
38049
+ const store = requireUploadStore("blobKeys");
38050
+ const files = [];
38051
+ for (const [i, blobKey] of keys.entries()) {
38052
+ const filename = names[i];
38053
+ let bytes;
38054
+ try {
38055
+ bytes = (await store.get(blobKey, { maxBytes: MAX_BLOB_FILE_BYTES })).bytes;
38056
+ } catch (e) {
38057
+ throw new Error(
38058
+ `${filename} could not be read from the upload store: ${e.message} PUT the file to its uploadUrl before calling this tool (files over ${MAX_BLOB_FILE_BYTES} bytes are refused), and mint a new slot with ${UPLOAD_SLOTS_TOOL} if this key was already used by a call that succeeded.`
38059
+ );
38060
+ }
38061
+ checkUploadSize(filename, bytes.length, MAX_BLOB_FILE_BYTES);
38062
+ const arg = { kind: "file", flag, contents: Buffer.from(bytes).toString("base64"), encoding: "base64", filename };
38063
+ if (opts.positional) arg.positional = true;
38064
+ files.push({ blobKey, filename, size: bytes.length, sha256: createHash4("sha256").update(bytes).digest("hex"), arg, store });
38065
+ }
38066
+ return files;
38067
+ }
38068
+ async function releaseBlobs(result, files) {
38069
+ if (files.length === 0) return result;
38070
+ if (result.isError) {
38071
+ return withNote(
38072
+ result,
38073
+ `The uploaded files (${files.map((f) => f.filename).join(", ")}) were kept in the upload store, so you can retry with the same blobKeys.`
38074
+ );
38075
+ }
38076
+ const failed = [];
38077
+ for (const file2 of files) {
38078
+ try {
38079
+ await file2.store.delete(file2.blobKey);
38080
+ } catch (e) {
38081
+ failed.push(`${file2.filename} (${e instanceof Error ? e.message : String(e)})`);
38082
+ }
38083
+ }
38084
+ if (failed.length === 0) return result;
38085
+ return withNote(
38086
+ result,
38087
+ `WARNING: the call succeeded, but these uploaded files could not be deleted from the upload store: ${failed.join("; ")}. They expire with the store's retention. Do NOT retry the call \u2014 it already happened.`
38088
+ );
38089
+ }
38090
+ function withNote(result, text) {
38091
+ return { ...result, content: [...result.content, { type: "text", text }] };
38092
+ }
38093
+ function hostedPathRefusal(path, param, alternative) {
38094
+ return new Error(
38095
+ `This server is remote (a hosted deployment), so \`${param}\` ${JSON.stringify(path)} names a file on the SERVER, not on your machine, and there is no such file here. Upload the bytes instead: call ${UPLOAD_SLOTS_TOOL} with files:[{filename}], PUT each file to its uploadUrl (the response includes a ready curl command), then call this tool again with those blobKeys in \`blobKeys\`` + (alternative ? ` \u2014 ${alternative}.` : ".")
38096
+ );
38097
+ }
38098
+ function confineUploadPath(path, param, alternative) {
38099
+ if (!isHostedDeployment()) return confinePath(path, param);
38100
+ try {
38101
+ confinePath(path, param);
38102
+ } catch {
38103
+ throw hostedPathRefusal(path, param, alternative);
38104
+ }
38105
+ if (!existsSync2(path)) throw hostedPathRefusal(path, param, alternative);
38106
+ return path;
38107
+ }
38108
+ function assertOneFileSource(sources) {
38109
+ const names = Object.keys(sources);
38110
+ const given = names.filter((n) => {
38111
+ const v = sources[n];
38112
+ return Array.isArray(v) ? v.length > 0 : v !== void 0;
38113
+ });
38114
+ if (given.length === 1) return;
38115
+ const list = `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
38116
+ throw new Error(`Pass exactly one of ${list}${given.length > 1 ? ` (got ${given.join(" and ")})` : ""}.`);
38117
+ }
38118
+
37511
38119
  // ../gogcli-mcp/src/tools/docs.ts
37512
38120
  function vetDocsRun(subcommand, args) {
37513
38121
  return vetCommentsRun("docs", subcommand, args);
@@ -37650,8 +38258,9 @@ var replySchema = {
37650
38258
  remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
37651
38259
  subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
37652
38260
  noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
37653
- attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), 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 a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).`),
38261
+ attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), 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 a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" \u2014 use blobKeys (gog_upload_slots) or, for a small file, attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server's GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).`),
37654
38262
  attachInline: attachInlineParam,
38263
+ blobKeys: blobKeysParam,
37655
38264
  from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
37656
38265
  autoFromAddressedAlias: external_exports.boolean().optional().describe("When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account's primary address \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
37657
38266
  ...signatureSchema,
@@ -37668,8 +38277,50 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
37668
38277
  'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.'
37669
38278
  );
37670
38279
 
38280
+ // ../gogcli-mcp/src/tools/uploads.ts
38281
+ var BLOB_KEY_TOOLS = [
38282
+ "gog_gmail_send",
38283
+ "gog_gmail_reply",
38284
+ "gog_gmail_reply_all",
38285
+ "gog_gmail_drafts_create",
38286
+ "gog_gmail_drafts_update",
38287
+ "gog_gmail_drafts_reply",
38288
+ "gog_gmail_drafts_reply_all",
38289
+ "gog_gmail_import",
38290
+ "gog_chat_messages_send",
38291
+ "gog_drive_upload",
38292
+ "gog_slides_add_slide",
38293
+ "gog_slides_insert_image",
38294
+ "gog_slides_replace_slide",
38295
+ "gog_docs_insert_image",
38296
+ "gog_docs_replace_image"
38297
+ ];
38298
+ var MIB2 = MAX_BLOB_FILE_BYTES / (1024 * 1024);
38299
+ function registerUploadTools(server) {
38300
+ server.registerTool(UPLOAD_SLOTS_TOOL, {
38301
+ description: `HOSTED DEPLOYMENTS: how to give this server a FILE you hold (a photo, a PDF, an .eml). This server runs remotely and cannot read paths on your machine, so call this first with the files' names: it returns one signed upload slot per file. PUT each file's bytes to its uploadUrl (each slot carries a ready \`curl\` command \u2014 replace the <path to \u2026> placeholder; the Content-Type must be exactly the slot's mimeType), then pass the slots' blobKeys in the \`blobKeys\` parameter of the tool that takes the file: ${BLOB_KEY_TOOLS.join(", ")}. Up to ${MAX_UPLOAD_FILES} files per call, ${MIB2} MiB each; the URLs expire after 30 minutes. Each uploaded file is deleted once the call that uses it succeeds, and kept if that call fails. Prefer this over attachInline for anything but a small file \u2014 no base64 in the call. Mints URLs only: nothing reaches Google until you use the keys. On a local install this refuses: pass a path, or attachInline.`,
38302
+ annotations: { readOnlyHint: true, openWorldHint: false },
38303
+ inputSchema: external_exports.object({
38304
+ files: external_exports.array(uploadSlotRequestSchema).min(1).max(MAX_UPLOAD_FILES).describe(
38305
+ "One entry per file: {filename, mimeType?}. The filename is the name the recipient / Drive will show, and its extension is how gog types an attachment \u2014 give it the right one."
38306
+ )
38307
+ })
38308
+ }, async ({ files }) => {
38309
+ const store = requireUploadStore(UPLOAD_SLOTS_TOOL);
38310
+ const slots = files.map((file2) => {
38311
+ const { expiresAtMs: _expiresAtMs, ...slot } = mintUploadSlot(store, file2);
38312
+ return slot;
38313
+ });
38314
+ return minifiedResult({
38315
+ slots,
38316
+ maxBytesPerFile: MAX_BLOB_FILE_BYTES,
38317
+ next: "PUT each file to its uploadUrl (run each slot's curl with the real path), check every PUT succeeded, then pass the blobKeys to the file-taking tool's blobKeys parameter."
38318
+ });
38319
+ });
38320
+ }
38321
+
37671
38322
  // ../gogcli-mcp/src/server.ts
37672
- var VERSION = true ? "4.9.1" : "0.0.0";
38323
+ var VERSION = true ? "4.10.0" : "0.0.0";
37673
38324
 
37674
38325
  // ../gogcli-mcp/src/bootstrap-auth.ts
37675
38326
  import { join as join7 } from "node:path";
@@ -37706,12 +38357,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
37706
38357
  const [clientId, clientSecret, refreshToken, account] = values;
37707
38358
  const { spawner } = options;
37708
38359
  try {
37709
- const { createHash: createHash4 } = await import("node:crypto");
38360
+ const { createHash: createHash5 } = await import("node:crypto");
37710
38361
  const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
37711
38362
  const home = options.home ?? (await import("node:os")).homedir();
37712
38363
  const dir = join7(home, ".gogcli-mcp");
37713
38364
  const marker = join7(dir, AUTH_BOOTSTRAP_MARKER);
37714
- const fingerprint = createHash4("sha256").update(values.join("\0")).digest("hex");
38365
+ const fingerprint = createHash5("sha256").update(values.join("\0")).digest("hex");
37715
38366
  const previous = await readFile(marker, "utf8").catch(() => void 0);
37716
38367
  if (previous === fingerprint && await accountListed(account, spawner)) return "present";
37717
38368
  await run(
@@ -37735,15 +38386,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
37735
38386
  }
37736
38387
  }
37737
38388
 
37738
- // ../gogcli-mcp/src/blob-urls.ts
37739
- var BLOB_URL_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
37740
- var BLOB_URL_CEILING_MARGIN_MS = 60 * 1e3;
37741
- var BLOB_URL_DEFAULT_TTL_MS = 60 * 60 * 1e3;
37742
-
37743
38389
  // ../gogcli-mcp/src/blob-upload.ts
37744
38390
  var MAX_BLOB_UPLOAD_BYTES = 100 * 1024 * 1024;
37745
38391
 
37746
38392
  // src/tools/docs-extra.ts
38393
+ var URL_ALTERNATIVE = "or pass a public HTTPS image `url`";
38394
+ var IMAGE_BLOB_NOTE = " On a hosted deployment, where no path on your machine exists, upload the image with gog_upload_slots and pass blobKeys instead of file.";
37747
38395
  function docTitle(raw) {
37748
38396
  try {
37749
38397
  const parsed = JSON.parse(raw);
@@ -38755,12 +39403,13 @@ function registerExtraDocsTools(server) {
38755
39403
  return runOrDiagnose(args, { account });
38756
39404
  });
38757
39405
  server.registerTool("gog_docs_insert_image", {
38758
- description: "Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.",
39406
+ description: "Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc." + IMAGE_BLOB_NOTE,
38759
39407
  annotations: { destructiveHint: true, openWorldHint: true },
38760
39408
  inputSchema: external_exports.object({
38761
39409
  docId: external_exports.string().describe("Doc ID (from the URL)"),
38762
- file: external_exports.string().optional().describe("Local PNG, JPEG, or GIF image to upload and insert (exactly one of file or url)"),
38763
- url: external_exports.string().optional().describe("Public HTTPS image URL to insert directly \u2014 no Drive upload or temporary public sharing (exactly one of file or url)"),
39410
+ file: external_exports.string().optional().describe("PNG, JPEG, or GIF image on the gog server to upload and insert (exactly one of file, url or blobKeys)"),
39411
+ url: external_exports.string().optional().describe("Public HTTPS image URL to insert directly \u2014 no Drive upload or temporary public sharing (exactly one of file, url or blobKeys)"),
39412
+ blobKeys: singleBlobKeyParam,
38764
39413
  at: external_exports.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
38765
39414
  before: external_exports.string().optional().describe("Insert the image immediately before the first literal text match (no text is deleted)"),
38766
39415
  after: external_exports.string().optional().describe("Insert the image immediately after the first literal text match (no text is deleted)"),
@@ -38772,10 +39421,13 @@ function registerExtraDocsTools(server) {
38772
39421
  tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
38773
39422
  account: accountParam
38774
39423
  })
38775
- }, async ({ docId, file: file2, url: url2, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
38776
- if (file2) confinePath(file2, "file");
39424
+ }, async ({ docId, file: file2, url: url2, blobKeys, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
39425
+ if (file2) confineUploadPath(file2, "file", URL_ALTERNATIVE);
39426
+ assertOneFileSource({ file: file2, url: url2, blobKeys });
39427
+ const blobs = await readBlobFiles(blobKeys, "file");
38777
39428
  const args = ["docs", "insert-image", pos(docId)];
38778
39429
  if (file2) args.push(`--file=${file2}`);
39430
+ for (const blob of blobs) args.push(blob.arg);
38779
39431
  if (url2) args.push(`--url=${url2}`);
38780
39432
  if (at) args.push(`--at=${at}`);
38781
39433
  if (before) args.push(`--before=${before}`);
@@ -38786,8 +39438,8 @@ function registerExtraDocsTools(server) {
38786
39438
  if (parent) args.push(`--parent=${parent}`);
38787
39439
  if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
38788
39440
  if (tab) args.push(`--tab=${tab}`);
38789
- if (file2) args.push("--force");
38790
- return runOrDiagnose(args, { account });
39441
+ if (file2 || blobs.length > 0) args.push("--force");
39442
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
38791
39443
  });
38792
39444
  server.registerTool("gog_docs_insert_person", {
38793
39445
  description: "Insert a native Google Docs person smart chip (the interactive @-mention chip) for an email address, at a character index or end-of-doc.",
@@ -39113,12 +39765,13 @@ function registerExtraDocsTools(server) {
39113
39765
  return runOrDiagnose(args, { account });
39114
39766
  });
39115
39767
  server.registerTool("gog_docs_replace_image", {
39116
- description: "Replace an existing image in a Google Doc in place \u2014 keeping its position and bounds \u2014 with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.",
39768
+ description: "Replace an existing image in a Google Doc in place \u2014 keeping its position and bounds \u2014 with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab." + IMAGE_BLOB_NOTE,
39117
39769
  annotations: { destructiveHint: true, openWorldHint: true },
39118
39770
  inputSchema: external_exports.object({
39119
39771
  docId: external_exports.string().describe("Doc ID (from the URL)"),
39120
- file: external_exports.string().optional().describe("Local PNG, JPEG, or GIF image to upload and use (exactly one of file or url)"),
39121
- url: external_exports.string().optional().describe("Public HTTPS image URL to use directly \u2014 no Drive upload (exactly one of file or url)"),
39772
+ file: external_exports.string().optional().describe("PNG, JPEG, or GIF image on the gog server to upload and use (exactly one of file, url or blobKeys)"),
39773
+ url: external_exports.string().optional().describe("Public HTTPS image URL to use directly \u2014 no Drive upload (exactly one of file, url or blobKeys)"),
39774
+ blobKeys: singleBlobKeyParam,
39122
39775
  objectId: external_exports.string().optional().describe("Exact image object ID to replace (from gog_docs_images_list)"),
39123
39776
  matchAlt: external_exports.string().optional().describe("Select the image whose alt text contains this value (case-insensitive)"),
39124
39777
  name: external_exports.string().optional().describe("Override the uploaded Drive filename (file mode)"),
@@ -39126,18 +39779,21 @@ function registerExtraDocsTools(server) {
39126
39779
  tab: external_exports.string().optional().describe("Target a specific tab by title or ID"),
39127
39780
  account: accountParam
39128
39781
  })
39129
- }, async ({ docId, file: file2, url: url2, objectId, matchAlt, name, parent, tab, account }) => {
39130
- if (file2) confinePath(file2, "file");
39782
+ }, async ({ docId, file: file2, url: url2, blobKeys, objectId, matchAlt, name, parent, tab, account }) => {
39783
+ if (file2) confineUploadPath(file2, "file", URL_ALTERNATIVE);
39784
+ assertOneFileSource({ file: file2, url: url2, blobKeys });
39785
+ const blobs = await readBlobFiles(blobKeys, "file");
39131
39786
  const args = ["docs", "replace-image", pos(docId)];
39132
39787
  if (file2) args.push(`--file=${file2}`);
39788
+ for (const blob of blobs) args.push(blob.arg);
39133
39789
  if (url2) args.push(`--url=${url2}`);
39134
39790
  if (objectId) args.push(`--object-id=${objectId}`);
39135
39791
  if (matchAlt) args.push(`--match-alt=${matchAlt}`);
39136
39792
  if (name) args.push(`--name=${name}`);
39137
39793
  if (parent) args.push(`--parent=${parent}`);
39138
39794
  if (tab) args.push(`--tab=${tab}`);
39139
- if (file2) args.push("--force");
39140
- return runOrDiagnose(args, { account });
39795
+ if (file2 || blobs.length > 0) args.push("--force");
39796
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
39141
39797
  });
39142
39798
  server.registerTool("gog_docs_table_row_pin_header", {
39143
39799
  description: "Pin (or unpin) leading rows of a native Google Docs table as repeating header rows. rows=N pins the first N rows; rows=0 unpins all header rows.",
@@ -39183,7 +39839,7 @@ await bootstrapGogAuth();
39183
39839
  await runMcp({
39184
39840
  name: "gogcli-docs",
39185
39841
  version: VERSION,
39186
- tools: [authToolsFor("docs"), registerDocsTools, registerExtraDocsTools]
39842
+ tools: [authToolsFor("docs"), registerDocsTools, registerExtraDocsTools, registerUploadTools]
39187
39843
  });
39188
39844
  /*! Bundled license information:
39189
39845
 
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-docs",
5
5
  "display_name": "gogcli (Docs)",
6
- "version": "4.9.1",
6
+ "version": "4.10.0",
7
7
  "description": "Extended Google Docs for Claude via gogcli — auth + full Docs and comments support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -414,6 +414,10 @@
414
414
  {
415
415
  "name": "gog_docs_insert_date_chip",
416
416
  "description": "Insert a native Google Docs date smart chip."
417
+ },
418
+ {
419
+ "name": "gog_upload_slots",
420
+ "description": "Get signed upload URLs so a hosted (remote) server can receive files you hold; pass the returned blobKeys to the tool that takes the file"
417
421
  }
418
422
  ],
419
423
  "compatibility": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-docs",
3
- "version": "4.9.1",
3
+ "version": "4.10.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-docs",
5
5
  "description": "Extended Google Docs MCP server via gogcli — all base tools plus full Docs support",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -24,7 +24,7 @@
24
24
  "test:coverage": "vitest run --coverage"
25
25
  },
26
26
  "dependencies": {
27
- "@chrischall/mcp-utils": "^3.4.0",
27
+ "@chrischall/mcp-utils": "^3.6.0",
28
28
  "@modelcontextprotocol/server": "^2.3.1",
29
29
  "zod": "^4.6.1"
30
30
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp-docs"
9
9
  },
10
- "version": "4.9.1",
10
+ "version": "4.10.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-docs",
15
- "version": "4.9.1",
15
+ "version": "4.10.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
- import { VERSION, authToolsFor, registerDocsTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
3
+ import { VERSION, authToolsFor, registerDocsTools, registerUploadTools, bootstrapGogAuth } from '../../gogcli-mcp/src/lib.js';
4
4
  import { registerExtraDocsTools } from './tools/docs-extra.js';
5
5
 
6
6
 
@@ -10,5 +10,5 @@ await bootstrapGogAuth();
10
10
  await runMcp({
11
11
  name: 'gogcli-docs',
12
12
  version: VERSION,
13
- tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools],
13
+ tools: [authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools],
14
14
  });
@@ -8,6 +8,11 @@ import {
8
8
  payloadArg,
9
9
  pos,
10
10
  confinePath,
11
+ assertOneFileSource,
12
+ confineUploadPath,
13
+ readBlobFiles,
14
+ releaseBlobs,
15
+ singleBlobKeyParam,
11
16
  bodyPreview,
12
17
  commentMentions,
13
18
  commentSnapshot,
@@ -18,6 +23,9 @@ import {
18
23
  } from '../../../gogcli-mcp/src/lib.js';
19
24
  import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
20
25
 
26
+ const URL_ALTERNATIVE = 'or pass a public HTTPS image `url`';
27
+ const IMAGE_BLOB_NOTE = ' On a hosted deployment, where no path on your machine exists, upload the image with gog_upload_slots and pass blobKeys instead of file.';
28
+
21
29
  /** A Doc's title from `gog docs info`, for a comment's prompt. Unreadable output names nothing. */
22
30
  export function docTitle(raw: string): string | undefined {
23
31
  try {
@@ -1135,12 +1143,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1135
1143
  });
1136
1144
 
1137
1145
  server.registerTool('gog_docs_insert_image', {
1138
- description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.',
1146
+ description: 'Insert an image into a Google Doc from a local file or a public HTTPS URL. file: uploaded to Drive, temporarily shared so Docs can fetch it, inserted, then the public permission is revoked. url: inserted directly with no Drive upload or temporary sharing. Replaces placeholder text (at) or appends at end-of-doc.' + IMAGE_BLOB_NOTE,
1139
1147
  annotations: { destructiveHint: true, openWorldHint: true },
1140
1148
  inputSchema: z.object({
1141
1149
  docId: z.string().describe('Doc ID (from the URL)'),
1142
- file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and insert (exactly one of file or url)'),
1143
- url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file or url)'),
1150
+ file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and insert (exactly one of file, url or blobKeys)'),
1151
+ url: z.string().optional().describe('Public HTTPS image URL to insert directly — no Drive upload or temporary public sharing (exactly one of file, url or blobKeys)'),
1152
+ blobKeys: singleBlobKeyParam,
1144
1153
  at: z.string().optional().describe('Placeholder text to replace, or "end" to append (default: end)'),
1145
1154
  before: z.string().optional().describe('Insert the image immediately before the first literal text match (no text is deleted)'),
1146
1155
  after: z.string().optional().describe('Insert the image immediately after the first literal text match (no text is deleted)'),
@@ -1152,10 +1161,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1152
1161
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1153
1162
  account: accountParam,
1154
1163
  }),
1155
- }, async ({ docId, file, url, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
1156
- if (file) confinePath(file, 'file');
1164
+ }, async ({ docId, file, url, blobKeys, at, before, after, width, height, name, parent, onRestricted, tab, account }) => {
1165
+ if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
1166
+ assertOneFileSource({ file, url, blobKeys });
1167
+ const blobs = await readBlobFiles(blobKeys, 'file');
1157
1168
  const args: GogArg[] = ['docs', 'insert-image', pos(docId)];
1158
1169
  if (file) args.push(`--file=${file}`);
1170
+ for (const blob of blobs) args.push(blob.arg);
1159
1171
  if (url) args.push(`--url=${url}`);
1160
1172
  if (at) args.push(`--at=${at}`);
1161
1173
  if (before) args.push(`--before=${before}`);
@@ -1166,8 +1178,8 @@ export function registerExtraDocsTools(server: McpServer): void {
1166
1178
  if (parent) args.push(`--parent=${parent}`);
1167
1179
  if (onRestricted) args.push(`--on-restricted=${onRestricted}`);
1168
1180
  if (tab) args.push(`--tab=${tab}`);
1169
- if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1170
- return runOrDiagnose(args, { account });
1181
+ if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1182
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
1171
1183
  });
1172
1184
 
1173
1185
  server.registerTool('gog_docs_insert_person', {
@@ -1517,12 +1529,13 @@ export function registerExtraDocsTools(server: McpServer): void {
1517
1529
  // --- gog 0.30 image replacement & table-row pin/style ---
1518
1530
 
1519
1531
  server.registerTool('gog_docs_replace_image', {
1520
- description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.',
1532
+ description: 'Replace an existing image in a Google Doc in place — keeping its position and bounds — with a new image from a local file or public HTTPS URL. Target the image by exact object ID (from gog_docs_images_list), by alt-text substring, or leave both off to replace the only image in the doc/tab.' + IMAGE_BLOB_NOTE,
1521
1533
  annotations: { destructiveHint: true, openWorldHint: true },
1522
1534
  inputSchema: z.object({
1523
1535
  docId: z.string().describe('Doc ID (from the URL)'),
1524
- file: z.string().optional().describe('Local PNG, JPEG, or GIF image to upload and use (exactly one of file or url)'),
1525
- url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file or url)'),
1536
+ file: z.string().optional().describe('PNG, JPEG, or GIF image on the gog server to upload and use (exactly one of file, url or blobKeys)'),
1537
+ url: z.string().optional().describe('Public HTTPS image URL to use directly — no Drive upload (exactly one of file, url or blobKeys)'),
1538
+ blobKeys: singleBlobKeyParam,
1526
1539
  objectId: z.string().optional().describe('Exact image object ID to replace (from gog_docs_images_list)'),
1527
1540
  matchAlt: z.string().optional().describe('Select the image whose alt text contains this value (case-insensitive)'),
1528
1541
  name: z.string().optional().describe('Override the uploaded Drive filename (file mode)'),
@@ -1530,18 +1543,21 @@ export function registerExtraDocsTools(server: McpServer): void {
1530
1543
  tab: z.string().optional().describe('Target a specific tab by title or ID'),
1531
1544
  account: accountParam,
1532
1545
  }),
1533
- }, async ({ docId, file, url, objectId, matchAlt, name, parent, tab, account }) => {
1534
- if (file) confinePath(file, 'file');
1546
+ }, async ({ docId, file, url, blobKeys, objectId, matchAlt, name, parent, tab, account }) => {
1547
+ if (file) confineUploadPath(file, 'file', URL_ALTERNATIVE);
1548
+ assertOneFileSource({ file, url, blobKeys });
1549
+ const blobs = await readBlobFiles(blobKeys, 'file');
1535
1550
  const args: GogArg[] = ['docs', 'replace-image', pos(docId)];
1536
1551
  if (file) args.push(`--file=${file}`);
1552
+ for (const blob of blobs) args.push(blob.arg);
1537
1553
  if (url) args.push(`--url=${url}`);
1538
1554
  if (objectId) args.push(`--object-id=${objectId}`);
1539
1555
  if (matchAlt) args.push(`--match-alt=${matchAlt}`);
1540
1556
  if (name) args.push(`--name=${name}`);
1541
1557
  if (parent) args.push(`--parent=${parent}`);
1542
1558
  if (tab) args.push(`--tab=${tab}`);
1543
- if (file) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1544
- return runOrDiagnose(args, { account });
1559
+ if (file || blobs.length > 0) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (local files upload via a temporary public share)
1560
+ return releaseBlobs(await runOrDiagnose(args, { account }), blobs);
1545
1561
  });
1546
1562
 
1547
1563
  server.registerTool('gog_docs_table_row_pin_header', {
@@ -1,10 +1,10 @@
1
- import { authToolsFor, registerDocsTools } from '../../gogcli-mcp/src/lib.js';
1
+ import { authToolsFor, registerDocsTools, registerUploadTools } from '../../gogcli-mcp/src/lib.js';
2
2
  import { registerExtraDocsTools } from '../src/tools/docs-extra.js';
3
3
  import { describeToolAnnotations, LOCAL_AUTH_TOOLS } from '../../gogcli-mcp/tests/support/tool-annotations.js';
4
4
 
5
5
  // The same registrar list src/index.ts hands to runMcp.
6
- describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools] as never, {
7
- tools: 83,
6
+ describeToolAnnotations([authToolsFor('docs'), registerDocsTools, registerExtraDocsTools, registerUploadTools] as never, {
7
+ tools: 84,
8
8
  destructive: 52,
9
- local: [...LOCAL_AUTH_TOOLS, 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
9
+ local: [...LOCAL_AUTH_TOOLS, 'gog_upload_slots', 'gog_batch_abort', 'gog_batch_begin', 'gog_batch_list', 'gog_batch_prune', 'gog_batch_show'],
10
10
  });
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
6
+ import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
7
+ import { blobStoreFromEnv, mintUploadSlot } from '@chrischall/mcp-utils/export';
8
+ import * as lib from '../../../gogcli-mcp/src/lib.js';
9
+ import type { GogArg, GogFileArg } from '../../../gogcli-mcp/src/lib.js';
10
+ import { registerExtraDocsTools } from '../../src/tools/docs-extra.js';
11
+ import { installFakeBlobStore, type FakeBlobStore } from '../../../gogcli-mcp/tests/support/fake-blob-store.js';
12
+
13
+ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof lib>();
15
+ return { ...actual, runOrDiagnose: vi.fn() };
16
+ });
17
+
18
+ const ORIGINAL_ENV = { ...process.env };
19
+ let fake: FakeBlobStore;
20
+ let harness: TestHarness;
21
+ let root: string;
22
+
23
+ beforeEach(async () => {
24
+ vi.clearAllMocks();
25
+ process.env = { ...ORIGINAL_ENV };
26
+ delete process.env.MCP_DATA_DIR;
27
+ root = mkdtempSync(join(tmpdir(), 'docs-blobkeys-'));
28
+ process.env.GOG_FILE_ROOTS = root;
29
+ fake = installFakeBlobStore();
30
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
31
+ harness = await createTestHarness(registerExtraDocsTools, {
32
+ elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
33
+ });
34
+ });
35
+
36
+ afterEach(() => {
37
+ process.env = ORIGINAL_ENV;
38
+ vi.unstubAllGlobals();
39
+ });
40
+
41
+ function upload(filename: string, bytes: string): string {
42
+ const key = mintUploadSlot(blobStoreFromEnv()!, { filename }).blobKey;
43
+ fake.put(key, bytes);
44
+ return key;
45
+ }
46
+
47
+ const lastArgs = () => vi.mocked(lib.runOrDiagnose).mock.calls.at(-1)![0] as GogArg[];
48
+ const fileArg = (args: GogArg[]) => args.find((a): a is GogFileArg => typeof a === 'object' && a.kind === 'file');
49
+
50
+ describe('docs image tools + blobKeys', () => {
51
+ it.each(['gog_docs_insert_image', 'gog_docs_replace_image'])('%s passes the blob as --file, with --force, and deletes it after', async (tool) => {
52
+ const key = upload('logo.png', 'PNGBYTES');
53
+ const result = await harness.callTool(tool, { docId: 'd1', blobKeys: [key] });
54
+ expect(result.isError).toBeFalsy();
55
+ const args = lastArgs();
56
+ expect(fileArg(args)).toMatchObject({ flag: 'file', filename: 'logo.png', encoding: 'base64' });
57
+ expect(fileArg(args)!.positional).toBeUndefined();
58
+ expect(args.at(-1)).toBe('--force');
59
+ expect(fake.deleted).toEqual([key]);
60
+ });
61
+
62
+ it('keeps the blob when gog fails', async () => {
63
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(errorResult('Error: restricted'));
64
+ const key = upload('logo.png', 'PNGBYTES');
65
+ await harness.callTool('gog_docs_insert_image', { docId: 'd1', blobKeys: [key] });
66
+ expect(fake.deleted).toEqual([]);
67
+ });
68
+
69
+ it('wants exactly one image source', async () => {
70
+ const result = await harness.callTool('gog_docs_replace_image', { docId: 'd1' });
71
+ expect(result.content[0]!.text).toMatch(/exactly one of file, url or blobKeys/);
72
+ });
73
+
74
+ it('url mode needs no --force', async () => {
75
+ await harness.callTool('gog_docs_insert_image', { docId: 'd1', url: 'https://x.test/i.png' });
76
+ expect(lastArgs()).not.toContain('--force');
77
+ });
78
+
79
+ it('on a hosted deployment, a file path that is not on the server points at the slot tool', async () => {
80
+ const result = await harness.callTool('gog_docs_insert_image', { docId: 'd1', file: '/Users/someone/a.png' });
81
+ expect(result.content[0]!.text).toMatch(/server is remote.*gog_upload_slots/s);
82
+ });
83
+ });