gogcli-mcp 2.18.2 → 2.18.4

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": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
10
- "version": "2.18.2"
10
+ "version": "2.18.4"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli",
16
16
  "source": "./",
17
17
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
18
- "version": "2.18.2",
18
+ "version": "2.18.4",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
3
  "displayName": "gogcli",
4
- "version": "2.18.2",
4
+ "version": "2.18.4",
5
5
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/index.js CHANGED
@@ -31285,6 +31285,198 @@ async function runBinary(args, options = {}) {
31285
31285
  return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
31286
31286
  }
31287
31287
 
31288
+ // src/timestamps.ts
31289
+ var DEFAULT_DISPLAY_TZ = "America/New_York";
31290
+ function isValidTimeZone(tz) {
31291
+ try {
31292
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
31293
+ return true;
31294
+ } catch {
31295
+ return false;
31296
+ }
31297
+ }
31298
+ function displayTimeZone() {
31299
+ const configured = readEnvVar("DISPLAY_TZ");
31300
+ if (configured && isValidTimeZone(configured)) return configured;
31301
+ return DEFAULT_DISPLAY_TZ;
31302
+ }
31303
+ function naiveSourceTimeZone() {
31304
+ const configured = readEnvVar("GOG_TIMEZONE");
31305
+ if (configured && isValidTimeZone(configured)) return configured;
31306
+ return displayTimeZone();
31307
+ }
31308
+ function offsetAt(instant, tz) {
31309
+ const w = wallPartsIn(instant, tz);
31310
+ const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
31311
+ const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
31312
+ const sign = minutes < 0 ? "-" : "+";
31313
+ const abs = Math.abs(minutes);
31314
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
31315
+ }
31316
+ function wallPartsIn(instant, tz) {
31317
+ const parts = new Intl.DateTimeFormat("en-US", {
31318
+ timeZone: tz,
31319
+ year: "numeric",
31320
+ month: "2-digit",
31321
+ day: "2-digit",
31322
+ hour: "2-digit",
31323
+ minute: "2-digit",
31324
+ second: "2-digit",
31325
+ // h23 pins midnight to hour 00; without it some ICU builds render hour 24.
31326
+ hourCycle: "h23"
31327
+ }).formatToParts(instant);
31328
+ const out = {};
31329
+ for (const p of parts) {
31330
+ if (p.type !== "literal") out[p.type] = Number(p.value);
31331
+ }
31332
+ return out;
31333
+ }
31334
+ function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
31335
+ let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
31336
+ for (let i = 0; i < 2; i += 1) {
31337
+ const seen = wallPartsIn(new Date(guess), tz);
31338
+ const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
31339
+ const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
31340
+ if (drift === 0) break;
31341
+ guess += drift;
31342
+ }
31343
+ return new Date(guess);
31344
+ }
31345
+ function pad(n, width = 2) {
31346
+ return String(n).padStart(width, "0");
31347
+ }
31348
+ function isoWithOffset(instant, tz, offset) {
31349
+ const w = wallPartsIn(instant, tz);
31350
+ const msPart = instant.getUTCMilliseconds();
31351
+ const frac = msPart ? `.${pad(msPart, 3)}` : "";
31352
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
31353
+ }
31354
+ function formatInstant(instant, tz = displayTimeZone()) {
31355
+ const offset = offsetAt(instant, tz);
31356
+ const display = new Intl.DateTimeFormat("en-US", {
31357
+ timeZone: tz,
31358
+ weekday: "short",
31359
+ month: "short",
31360
+ day: "numeric",
31361
+ year: "numeric",
31362
+ hour: "numeric",
31363
+ minute: "2-digit",
31364
+ timeZoneName: "short"
31365
+ }).format(instant);
31366
+ return { iso: isoWithOffset(instant, tz, offset), display };
31367
+ }
31368
+ var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
31369
+ "date",
31370
+ // gog gmail message/thread listings ("2026-07-28 03:36")
31371
+ "dateTime",
31372
+ // Calendar event start/end
31373
+ "internalDate",
31374
+ // Gmail, epoch milliseconds (authoritative)
31375
+ "modifiedTime",
31376
+ // Drive
31377
+ "createdTime",
31378
+ // Drive
31379
+ "createTime",
31380
+ "updateTime",
31381
+ "updated",
31382
+ "originalStartTime",
31383
+ "expirationTime",
31384
+ "lastModified",
31385
+ "sentAt",
31386
+ "viewedAt",
31387
+ "modifiedAt",
31388
+ "fetchedBodyAt",
31389
+ "asOf"
31390
+ ]);
31391
+ var ZONE_NAME_KEYS = /* @__PURE__ */ new Set(["timeZone", "timezone"]);
31392
+ var RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
31393
+ var NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
31394
+ var EPOCH_MILLIS = /^\d{13}$/;
31395
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
31396
+ function isRealCalendarDate(p) {
31397
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
31398
+ return utc.getUTCFullYear() === p.year && utc.getUTCMonth() === p.month - 1 && utc.getUTCDate() === p.day && utc.getUTCHours() === p.hour && utc.getUTCMinutes() === p.minute && utc.getUTCSeconds() === p.second;
31399
+ }
31400
+ function parseTimestampValue(key, value, assumeNaiveIn) {
31401
+ if (typeof value !== "string") return null;
31402
+ const raw = value.trim();
31403
+ if (raw === "" || DATE_ONLY.test(raw)) return null;
31404
+ if (key === "internalDate" && EPOCH_MILLIS.test(raw)) {
31405
+ return new Date(Number(raw));
31406
+ }
31407
+ if (RFC3339_WITH_OFFSET.test(raw)) {
31408
+ const parsed = new Date(raw.replace(" ", "T"));
31409
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
31410
+ }
31411
+ const naive = NAIVE_DATE_TIME.exec(raw);
31412
+ if (naive) {
31413
+ const [, y, mo, d, h, mi, s, frac] = naive;
31414
+ const ms = frac ? Number(frac.padEnd(3, "0").slice(0, 3)) : 0;
31415
+ const parts = {
31416
+ year: Number(y),
31417
+ month: Number(mo),
31418
+ day: Number(d),
31419
+ hour: Number(h),
31420
+ minute: Number(mi),
31421
+ second: Number(s ?? "0")
31422
+ };
31423
+ if (!isRealCalendarDate(parts)) return null;
31424
+ return wallTimeToInstant(
31425
+ parts.year,
31426
+ parts.month,
31427
+ parts.day,
31428
+ parts.hour,
31429
+ parts.minute,
31430
+ parts.second,
31431
+ ms,
31432
+ assumeNaiveIn
31433
+ );
31434
+ }
31435
+ return null;
31436
+ }
31437
+ function walk(node, tz, naiveTz) {
31438
+ let changed = false;
31439
+ if (Array.isArray(node)) {
31440
+ for (const item of node) {
31441
+ if (walk(item, tz, naiveTz)) changed = true;
31442
+ }
31443
+ return changed;
31444
+ }
31445
+ if (node === null || typeof node !== "object") return false;
31446
+ const obj = node;
31447
+ for (const key of Object.keys(obj)) {
31448
+ const value = obj[key];
31449
+ if (value !== null && typeof value === "object") {
31450
+ if (walk(value, tz, naiveTz)) changed = true;
31451
+ continue;
31452
+ }
31453
+ if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
31454
+ const instant = parseTimestampValue(key, value, naiveTz);
31455
+ if (!instant) continue;
31456
+ const { iso, display } = formatInstant(instant, tz);
31457
+ obj[key] = iso;
31458
+ obj[`${key}Display`] = display;
31459
+ changed = true;
31460
+ }
31461
+ return changed;
31462
+ }
31463
+ function detectIndent(text) {
31464
+ const match = /\n(\s+)\S/.exec(text);
31465
+ return match ? match[1].replace(/\t/g, " ").length : 0;
31466
+ }
31467
+ function normalizeTimestamps(text, tz = displayTimeZone(), naiveTz = naiveSourceTimeZone()) {
31468
+ const trimmed = text.trim();
31469
+ if (trimmed === "" || !/^[[{]/.test(trimmed)) return text;
31470
+ let parsed;
31471
+ try {
31472
+ parsed = JSON.parse(trimmed);
31473
+ } catch {
31474
+ return text;
31475
+ }
31476
+ if (!walk(parsed, tz, naiveTz)) return text;
31477
+ return JSON.stringify(parsed, null, detectIndent(text));
31478
+ }
31479
+
31288
31480
  // src/tools/utils.ts
31289
31481
  var PAYLOAD_INLINE_MAX = 4096;
31290
31482
  function payloadArg(inlineFlag, fileFlag, value, ext) {
@@ -31385,7 +31577,8 @@ ${accounts || "(none)"}${hint}`);
31385
31577
  }
31386
31578
  async function runOrDiagnose(args, options) {
31387
31579
  try {
31388
- return rawTextResult(await run(args, options));
31580
+ const raw = await run(args, options);
31581
+ return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
31389
31582
  } catch (err) {
31390
31583
  return diagnose(err);
31391
31584
  }
@@ -32457,14 +32650,16 @@ function registerDriveTools(server) {
32457
32650
  const { name, mimeType } = fileMeta(await run(["drive", "get", fileId], { account }));
32458
32651
  const params = JSON.stringify({ fileId, alt: "media" });
32459
32652
  const blob = await runBinary(["api", "call", "drive", "v3", "files.get", `--params=${params}`], { account });
32653
+ const type = mimeType ?? "application/octet-stream";
32654
+ const unrenderableHint = type.startsWith("image/") ? "" : ` If your client does not render a ${type} resource, gog_drive_extract_text returns the same file as text (PDFs included, via OCR).`;
32460
32655
  return {
32461
32656
  content: [
32462
- { type: "text", text: `${name ?? fileId} (${mimeType ?? "application/octet-stream"}) \u2014 ${Buffer.from(blob, "base64").length} bytes, base64 resource below.` },
32657
+ { type: "text", text: `${name ?? fileId} (${type}) \u2014 ${Buffer.from(blob, "base64").length} bytes, base64 resource below.${unrenderableHint}` },
32463
32658
  {
32464
32659
  type: "resource",
32465
32660
  resource: {
32466
32661
  uri: `gogdrive://${fileId}/${encodeURIComponent(name ?? "file")}`,
32467
- mimeType: mimeType ?? "application/octet-stream",
32662
+ mimeType: type,
32468
32663
  blob
32469
32664
  }
32470
32665
  }
@@ -32857,7 +33052,7 @@ function registerTasksTools(server) {
32857
33052
  }
32858
33053
 
32859
33054
  // src/server.ts
32860
- var VERSION = true ? "2.18.2" : "0.0.0";
33055
+ var VERSION = true ? "2.18.4" : "0.0.0";
32861
33056
  var BASE_TOOL_REGISTRARS = [
32862
33057
  registerApiTools,
32863
33058
  registerAuthTools,
package/dist/lib.js CHANGED
@@ -23183,6 +23183,198 @@ async function runBinary(args, options = {}) {
23183
23183
  return spawnExecutor(fullArgs, { timeout, interactive: false, spawner, binary: true });
23184
23184
  }
23185
23185
 
23186
+ // src/timestamps.ts
23187
+ var DEFAULT_DISPLAY_TZ = "America/New_York";
23188
+ function isValidTimeZone(tz) {
23189
+ try {
23190
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
23191
+ return true;
23192
+ } catch {
23193
+ return false;
23194
+ }
23195
+ }
23196
+ function displayTimeZone() {
23197
+ const configured = readEnvVar("DISPLAY_TZ");
23198
+ if (configured && isValidTimeZone(configured)) return configured;
23199
+ return DEFAULT_DISPLAY_TZ;
23200
+ }
23201
+ function naiveSourceTimeZone() {
23202
+ const configured = readEnvVar("GOG_TIMEZONE");
23203
+ if (configured && isValidTimeZone(configured)) return configured;
23204
+ return displayTimeZone();
23205
+ }
23206
+ function offsetAt(instant, tz) {
23207
+ const w = wallPartsIn(instant, tz);
23208
+ const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
23209
+ const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
23210
+ const sign = minutes < 0 ? "-" : "+";
23211
+ const abs = Math.abs(minutes);
23212
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
23213
+ }
23214
+ function wallPartsIn(instant, tz) {
23215
+ const parts = new Intl.DateTimeFormat("en-US", {
23216
+ timeZone: tz,
23217
+ year: "numeric",
23218
+ month: "2-digit",
23219
+ day: "2-digit",
23220
+ hour: "2-digit",
23221
+ minute: "2-digit",
23222
+ second: "2-digit",
23223
+ // h23 pins midnight to hour 00; without it some ICU builds render hour 24.
23224
+ hourCycle: "h23"
23225
+ }).formatToParts(instant);
23226
+ const out = {};
23227
+ for (const p of parts) {
23228
+ if (p.type !== "literal") out[p.type] = Number(p.value);
23229
+ }
23230
+ return out;
23231
+ }
23232
+ function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
23233
+ let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
23234
+ for (let i = 0; i < 2; i += 1) {
23235
+ const seen = wallPartsIn(new Date(guess), tz);
23236
+ const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
23237
+ const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
23238
+ if (drift === 0) break;
23239
+ guess += drift;
23240
+ }
23241
+ return new Date(guess);
23242
+ }
23243
+ function pad(n, width = 2) {
23244
+ return String(n).padStart(width, "0");
23245
+ }
23246
+ function isoWithOffset(instant, tz, offset) {
23247
+ const w = wallPartsIn(instant, tz);
23248
+ const msPart = instant.getUTCMilliseconds();
23249
+ const frac = msPart ? `.${pad(msPart, 3)}` : "";
23250
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
23251
+ }
23252
+ function formatInstant(instant, tz = displayTimeZone()) {
23253
+ const offset = offsetAt(instant, tz);
23254
+ const display = new Intl.DateTimeFormat("en-US", {
23255
+ timeZone: tz,
23256
+ weekday: "short",
23257
+ month: "short",
23258
+ day: "numeric",
23259
+ year: "numeric",
23260
+ hour: "numeric",
23261
+ minute: "2-digit",
23262
+ timeZoneName: "short"
23263
+ }).format(instant);
23264
+ return { iso: isoWithOffset(instant, tz, offset), display };
23265
+ }
23266
+ var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
23267
+ "date",
23268
+ // gog gmail message/thread listings ("2026-07-28 03:36")
23269
+ "dateTime",
23270
+ // Calendar event start/end
23271
+ "internalDate",
23272
+ // Gmail, epoch milliseconds (authoritative)
23273
+ "modifiedTime",
23274
+ // Drive
23275
+ "createdTime",
23276
+ // Drive
23277
+ "createTime",
23278
+ "updateTime",
23279
+ "updated",
23280
+ "originalStartTime",
23281
+ "expirationTime",
23282
+ "lastModified",
23283
+ "sentAt",
23284
+ "viewedAt",
23285
+ "modifiedAt",
23286
+ "fetchedBodyAt",
23287
+ "asOf"
23288
+ ]);
23289
+ var ZONE_NAME_KEYS = /* @__PURE__ */ new Set(["timeZone", "timezone"]);
23290
+ var RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
23291
+ var NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
23292
+ var EPOCH_MILLIS = /^\d{13}$/;
23293
+ var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
23294
+ function isRealCalendarDate(p) {
23295
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
23296
+ return utc.getUTCFullYear() === p.year && utc.getUTCMonth() === p.month - 1 && utc.getUTCDate() === p.day && utc.getUTCHours() === p.hour && utc.getUTCMinutes() === p.minute && utc.getUTCSeconds() === p.second;
23297
+ }
23298
+ function parseTimestampValue(key, value, assumeNaiveIn) {
23299
+ if (typeof value !== "string") return null;
23300
+ const raw = value.trim();
23301
+ if (raw === "" || DATE_ONLY.test(raw)) return null;
23302
+ if (key === "internalDate" && EPOCH_MILLIS.test(raw)) {
23303
+ return new Date(Number(raw));
23304
+ }
23305
+ if (RFC3339_WITH_OFFSET.test(raw)) {
23306
+ const parsed = new Date(raw.replace(" ", "T"));
23307
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
23308
+ }
23309
+ const naive = NAIVE_DATE_TIME.exec(raw);
23310
+ if (naive) {
23311
+ const [, y, mo, d, h, mi, s, frac] = naive;
23312
+ const ms = frac ? Number(frac.padEnd(3, "0").slice(0, 3)) : 0;
23313
+ const parts = {
23314
+ year: Number(y),
23315
+ month: Number(mo),
23316
+ day: Number(d),
23317
+ hour: Number(h),
23318
+ minute: Number(mi),
23319
+ second: Number(s ?? "0")
23320
+ };
23321
+ if (!isRealCalendarDate(parts)) return null;
23322
+ return wallTimeToInstant(
23323
+ parts.year,
23324
+ parts.month,
23325
+ parts.day,
23326
+ parts.hour,
23327
+ parts.minute,
23328
+ parts.second,
23329
+ ms,
23330
+ assumeNaiveIn
23331
+ );
23332
+ }
23333
+ return null;
23334
+ }
23335
+ function walk(node, tz, naiveTz) {
23336
+ let changed = false;
23337
+ if (Array.isArray(node)) {
23338
+ for (const item of node) {
23339
+ if (walk(item, tz, naiveTz)) changed = true;
23340
+ }
23341
+ return changed;
23342
+ }
23343
+ if (node === null || typeof node !== "object") return false;
23344
+ const obj = node;
23345
+ for (const key of Object.keys(obj)) {
23346
+ const value = obj[key];
23347
+ if (value !== null && typeof value === "object") {
23348
+ if (walk(value, tz, naiveTz)) changed = true;
23349
+ continue;
23350
+ }
23351
+ if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
23352
+ const instant = parseTimestampValue(key, value, naiveTz);
23353
+ if (!instant) continue;
23354
+ const { iso, display } = formatInstant(instant, tz);
23355
+ obj[key] = iso;
23356
+ obj[`${key}Display`] = display;
23357
+ changed = true;
23358
+ }
23359
+ return changed;
23360
+ }
23361
+ function detectIndent(text) {
23362
+ const match = /\n(\s+)\S/.exec(text);
23363
+ return match ? match[1].replace(/\t/g, " ").length : 0;
23364
+ }
23365
+ function normalizeTimestamps(text, tz = displayTimeZone(), naiveTz = naiveSourceTimeZone()) {
23366
+ const trimmed = text.trim();
23367
+ if (trimmed === "" || !/^[[{]/.test(trimmed)) return text;
23368
+ let parsed;
23369
+ try {
23370
+ parsed = JSON.parse(trimmed);
23371
+ } catch {
23372
+ return text;
23373
+ }
23374
+ if (!walk(parsed, tz, naiveTz)) return text;
23375
+ return JSON.stringify(parsed, null, detectIndent(text));
23376
+ }
23377
+
23186
23378
  // src/tools/utils.ts
23187
23379
  var PAYLOAD_INLINE_MAX = 4096;
23188
23380
  function payloadArg(inlineFlag, fileFlag, value, ext) {
@@ -23288,7 +23480,8 @@ ${accounts || "(none)"}${hint}`);
23288
23480
  }
23289
23481
  async function runOrDiagnose(args, options) {
23290
23482
  try {
23291
- return rawTextResult(await run(args, options));
23483
+ const raw = await run(args, options);
23484
+ return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
23292
23485
  } catch (err) {
23293
23486
  return diagnose(err);
23294
23487
  }
@@ -24363,14 +24556,16 @@ function registerDriveTools(server) {
24363
24556
  const { name, mimeType } = fileMeta(await run(["drive", "get", fileId], { account }));
24364
24557
  const params = JSON.stringify({ fileId, alt: "media" });
24365
24558
  const blob = await runBinary(["api", "call", "drive", "v3", "files.get", `--params=${params}`], { account });
24559
+ const type = mimeType ?? "application/octet-stream";
24560
+ const unrenderableHint = type.startsWith("image/") ? "" : ` If your client does not render a ${type} resource, gog_drive_extract_text returns the same file as text (PDFs included, via OCR).`;
24366
24561
  return {
24367
24562
  content: [
24368
- { type: "text", text: `${name ?? fileId} (${mimeType ?? "application/octet-stream"}) \u2014 ${Buffer.from(blob, "base64").length} bytes, base64 resource below.` },
24563
+ { type: "text", text: `${name ?? fileId} (${type}) \u2014 ${Buffer.from(blob, "base64").length} bytes, base64 resource below.${unrenderableHint}` },
24369
24564
  {
24370
24565
  type: "resource",
24371
24566
  resource: {
24372
24567
  uri: `gogdrive://${fileId}/${encodeURIComponent(name ?? "file")}`,
24373
- mimeType: mimeType ?? "application/octet-stream",
24568
+ mimeType: type,
24374
24569
  blob
24375
24570
  }
24376
24571
  }
@@ -24763,7 +24958,7 @@ function registerTasksTools(server) {
24763
24958
  }
24764
24959
 
24765
24960
  // src/server.ts
24766
- var VERSION = true ? "2.18.2" : "0.0.0";
24961
+ var VERSION = true ? "2.18.4" : "0.0.0";
24767
24962
  var BASE_TOOL_REGISTRARS = [
24768
24963
  registerApiTools,
24769
24964
  registerAuthTools,
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "2.18.2",
6
+ "version": "2.18.4",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.18.2",
3
+ "version": "2.18.4",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
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"
9
9
  },
10
- "version": "2.18.2",
10
+ "version": "2.18.4",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.18.2",
15
+ "version": "2.18.4",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,312 @@
1
+ // Canonical timestamp handling for every gog response.
2
+ //
3
+ // gog renders message/thread dates in its configured timezone (GOG_TIMEZONE)
4
+ // and emits several shapes: naive wall-clock ("2026-07-28 03:36"), naive ISO,
5
+ // RFC3339 with a real offset (straight from a Google API), and epoch
6
+ // milliseconds (Gmail's internalDate). Unlabeled values are the dangerous
7
+ // ones: a reader assumes local time and is wrong by the UTC offset, which can
8
+ // put an event on the wrong calendar DAY — the failure that actually matters
9
+ // when reasoning about response windows and day boundaries.
10
+ //
11
+ // Every value that survives detection is rewritten to ISO-8601 WITH an
12
+ // explicit offset and paired with a `<key>Display` sibling rendered in the
13
+ // operator's zone, weekday included, because a wrong weekday is what makes a
14
+ // date-boundary error visible at a glance.
15
+
16
+ import { readEnvVar } from '@chrischall/mcp-utils';
17
+
18
+ // Fallback display zone for this deployment. IANA name, never a fixed offset —
19
+ // a hardcoded -04:00 would be an hour wrong from November through March.
20
+ export const DEFAULT_DISPLAY_TZ = 'America/New_York';
21
+
22
+ function isValidTimeZone(tz: string): boolean {
23
+ try {
24
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ // The zone all *Display fields render in, and the zone a NAIVE source value is
32
+ // assumed to be wall-clock in. Keep GOG_TIMEZONE on the gog side in sync with
33
+ // this: gog formats naive values in its own zone, and we re-attach the offset
34
+ // using ours. An invalid DISPLAY_TZ falls back rather than throwing, so a typo
35
+ // degrades the label instead of breaking every tool.
36
+ export function displayTimeZone(): string {
37
+ const configured = readEnvVar('DISPLAY_TZ');
38
+ if (configured && isValidTimeZone(configured)) return configured;
39
+ return DEFAULT_DISPLAY_TZ;
40
+ }
41
+
42
+ // The zone a NAIVE source value is wall-clock in — which is whatever zone gog
43
+ // formatted it in, i.e. GOG_TIMEZONE. Reading it directly rather than assuming
44
+ // it equals DISPLAY_TZ removes an invisible "keep these two in sync"
45
+ // requirement: if they ever diverged, every naive value would silently gain the
46
+ // wrong offset and nothing would surface it. Falls back to the display zone,
47
+ // which is the correct guess when gog is running with the same configuration.
48
+ export function naiveSourceTimeZone(): string {
49
+ const configured = readEnvVar('GOG_TIMEZONE');
50
+ if (configured && isValidTimeZone(configured)) return configured;
51
+ return displayTimeZone();
52
+ }
53
+
54
+ // Offset of `tz` at a given instant, as "+HH:MM"/"-HH:MM". Uses the IANA
55
+ // database via Intl, so DST is handled per-instant rather than per-zone.
56
+ function offsetAt(instant: Date, tz: string): string {
57
+ // Derived arithmetically rather than parsed out of Intl's "GMT-04:00" label:
58
+ // the gap between the zone's wall clock and the instant IS the offset, and
59
+ // zone offsets are always whole minutes.
60
+ const w = wallPartsIn(instant, tz);
61
+ const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
62
+ const minutes = Math.round((asUTC - instant.getTime()) / 60_000);
63
+ const sign = minutes < 0 ? '-' : '+';
64
+ const abs = Math.abs(minutes);
65
+ return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
66
+ }
67
+
68
+ // Wall-clock fields of `instant` as seen in `tz`, via Intl so the IANA rules
69
+ // (including DST) apply.
70
+ function wallPartsIn(instant: Date, tz: string): Record<string, number> {
71
+ const parts = new Intl.DateTimeFormat('en-US', {
72
+ timeZone: tz,
73
+ year: 'numeric',
74
+ month: '2-digit',
75
+ day: '2-digit',
76
+ hour: '2-digit',
77
+ minute: '2-digit',
78
+ second: '2-digit',
79
+ // h23 pins midnight to hour 00; without it some ICU builds render hour 24.
80
+ hourCycle: 'h23',
81
+ }).formatToParts(instant);
82
+ const out: Record<string, number> = {};
83
+ for (const p of parts) {
84
+ if (p.type !== 'literal') out[p.type] = Number(p.value);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // Interpret naive wall-clock fields as an instant in `tz`. There is no direct
90
+ // inverse of the zone rules, so guess UTC, measure how far the guess lands from
91
+ // the requested wall time in that zone, and correct. Two passes settle the case
92
+ // where the correction itself crosses a DST boundary.
93
+ function wallTimeToInstant(
94
+ y: number, mo: number, d: number, h: number, mi: number, s: number, ms: number, tz: string,
95
+ ): Date {
96
+ let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
97
+ for (let i = 0; i < 2; i += 1) {
98
+ const seen = wallPartsIn(new Date(guess), tz);
99
+ const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
100
+ const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
101
+ if (drift === 0) break;
102
+ guess += drift;
103
+ }
104
+ return new Date(guess);
105
+ }
106
+
107
+ export interface CanonicalTimestamp {
108
+ /** ISO-8601 with an explicit offset, e.g. 2026-07-27T23:31:09-04:00. */
109
+ iso: string;
110
+ /** Human rendering in the display zone, weekday first. */
111
+ display: string;
112
+ }
113
+
114
+ function pad(n: number, width = 2): string {
115
+ return String(n).padStart(width, '0');
116
+ }
117
+
118
+ // Render `instant` as ISO-8601 carrying `offset`'s wall time and label.
119
+ function isoWithOffset(instant: Date, tz: string, offset: string): string {
120
+ const w = wallPartsIn(instant, tz);
121
+ const msPart = instant.getUTCMilliseconds();
122
+ const frac = msPart ? `.${pad(msPart, 3)}` : '';
123
+ return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
124
+ }
125
+
126
+ // The one place an instant becomes user-visible text. Every emitted timestamp
127
+ // goes through here, so no call site can reintroduce a naive value.
128
+ export function formatInstant(instant: Date, tz = displayTimeZone()): CanonicalTimestamp {
129
+ const offset = offsetAt(instant, tz);
130
+ const display = new Intl.DateTimeFormat('en-US', {
131
+ timeZone: tz,
132
+ weekday: 'short',
133
+ month: 'short',
134
+ day: 'numeric',
135
+ year: 'numeric',
136
+ hour: 'numeric',
137
+ minute: '2-digit',
138
+ timeZoneName: 'short',
139
+ }).format(instant);
140
+ return { iso: isoWithOffset(instant, tz, offset), display };
141
+ }
142
+
143
+ // Keys whose STRING values are timestamps in gog/Google payloads. Deliberately
144
+ // an allowlist: near-miss names abound (updatedCells, updatedRange, updatedRows,
145
+ // formattedValue, verificationStatus) and a name-pattern match would rewrite
146
+ // spreadsheet cell data. A value must ALSO match a timestamp shape below, so
147
+ // both the key and the value have to agree before anything is touched.
148
+ const TIMESTAMP_KEYS = new Set([
149
+ 'date', // gog gmail message/thread listings ("2026-07-28 03:36")
150
+ 'dateTime', // Calendar event start/end
151
+ 'internalDate', // Gmail, epoch milliseconds (authoritative)
152
+ 'modifiedTime', // Drive
153
+ 'createdTime', // Drive
154
+ 'createTime',
155
+ 'updateTime',
156
+ 'updated',
157
+ 'originalStartTime',
158
+ 'expirationTime',
159
+ 'lastModified',
160
+ 'sentAt',
161
+ 'viewedAt',
162
+ 'modifiedAt',
163
+ 'fetchedBodyAt',
164
+ 'asOf',
165
+ ]);
166
+
167
+ // Keys that hold a zone NAME rather than an instant. They cannot match a
168
+ // timestamp shape anyway, but naming them documents the hazard.
169
+ const ZONE_NAME_KEYS = new Set(['timeZone', 'timezone']);
170
+
171
+ const RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
172
+ const NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
173
+ // Exactly 13 digits: milliseconds. A 10-digit run is epoch SECONDS, and
174
+ // reading one as milliseconds dates it to 1970.
175
+ const EPOCH_MILLIS = /^\d{13}$/;
176
+
177
+ // A bare YYYY-MM-DD is a DATE, not an instant — Calendar uses it for all-day
178
+ // events. Converting one would invent a time that the source never asserted.
179
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
180
+
181
+ // True when the components describe a real calendar instant. Guards against
182
+ // Date.UTC's silent rollover of out-of-range values.
183
+ function isRealCalendarDate(p: {
184
+ year: number; month: number; day: number; hour: number; minute: number; second: number;
185
+ }): boolean {
186
+ const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
187
+ return utc.getUTCFullYear() === p.year
188
+ && utc.getUTCMonth() === p.month - 1
189
+ && utc.getUTCDate() === p.day
190
+ && utc.getUTCHours() === p.hour
191
+ && utc.getUTCMinutes() === p.minute
192
+ && utc.getUTCSeconds() === p.second;
193
+ }
194
+
195
+ // Resolve a raw field value to an instant, or null when it is not a timestamp.
196
+ // `assumeNaiveIn` is the zone a naive (offset-less) value is wall-clock in.
197
+ export function parseTimestampValue(
198
+ key: string,
199
+ value: unknown,
200
+ assumeNaiveIn: string,
201
+ ): Date | null {
202
+ if (typeof value !== 'string') return null;
203
+ const raw = value.trim();
204
+ if (raw === '' || DATE_ONLY.test(raw)) return null;
205
+
206
+ // EPOCH_MILLIS is a bounded digit run, so Number() is always finite here.
207
+ if (key === 'internalDate' && EPOCH_MILLIS.test(raw)) {
208
+ return new Date(Number(raw));
209
+ }
210
+
211
+ if (RFC3339_WITH_OFFSET.test(raw)) {
212
+ // The source already knows its offset; trust it verbatim.
213
+ const parsed = new Date(raw.replace(' ', 'T'));
214
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
215
+ }
216
+
217
+ const naive = NAIVE_DATE_TIME.exec(raw);
218
+ if (naive) {
219
+ const [, y, mo, d, h, mi, s, frac] = naive;
220
+ const ms = frac ? Number(frac.padEnd(3, '0').slice(0, 3)) : 0;
221
+ const parts = {
222
+ year: Number(y), month: Number(mo), day: Number(d),
223
+ hour: Number(h), minute: Number(mi), second: Number(s ?? '0'),
224
+ };
225
+ // Date.UTC silently rolls impossible components over — month 99 becomes
226
+ // 2034, Feb 30 becomes Mar 2 — so a typo would surface as a confident wrong
227
+ // date rather than a rejection. The RFC3339 branch above already returns
228
+ // null for the same input; match it.
229
+ //
230
+ // Checked in UTC space, deliberately: validating against the ZONE's wall
231
+ // clock would also reject a non-existent spring-forward time like
232
+ // 2026-03-08 02:30 ET, and shifting such a value forward (as zone libraries
233
+ // do) is better than dropping a timestamp we can place to within an hour.
234
+ if (!isRealCalendarDate(parts)) return null;
235
+ return wallTimeToInstant(
236
+ parts.year, parts.month, parts.day, parts.hour, parts.minute, parts.second, ms, assumeNaiveIn,
237
+ );
238
+ }
239
+ return null;
240
+ }
241
+
242
+ // True when a string carries no zone information — the shape this whole module
243
+ // exists to eliminate. Used by the contract test.
244
+ export function isNaiveTimestamp(value: unknown): boolean {
245
+ return typeof value === 'string' && NAIVE_DATE_TIME.test(value.trim());
246
+ }
247
+
248
+ // Walk a parsed gog payload, rewriting every allowlisted timestamp to canonical
249
+ // form and attaching its display sibling. Mutates and returns `node`.
250
+ function walk(node: unknown, tz: string, naiveTz: string): boolean {
251
+ let changed = false;
252
+ if (Array.isArray(node)) {
253
+ for (const item of node) {
254
+ if (walk(item, tz, naiveTz)) changed = true;
255
+ }
256
+ return changed;
257
+ }
258
+ if (node === null || typeof node !== 'object') return false;
259
+
260
+ const obj = node as Record<string, unknown>;
261
+ for (const key of Object.keys(obj)) {
262
+ const value = obj[key];
263
+ if (value !== null && typeof value === 'object') {
264
+ if (walk(value, tz, naiveTz)) changed = true;
265
+ continue;
266
+ }
267
+ if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
268
+ const instant = parseTimestampValue(key, value, naiveTz);
269
+ if (!instant) continue;
270
+ const { iso, display } = formatInstant(instant, tz);
271
+ obj[key] = iso;
272
+ obj[`${key}Display`] = display;
273
+ changed = true;
274
+ }
275
+ return changed;
276
+ }
277
+
278
+ // gog's --pretty emits indented JSON. Re-serializing compactly would silently
279
+ // undo a formatting choice the caller explicitly asked for, so mirror whatever
280
+ // indentation the original used.
281
+ function detectIndent(text: string): number {
282
+ const match = /\n(\s+)\S/.exec(text);
283
+ return match ? match[1].replace(/\t/g, ' ').length : 0;
284
+ }
285
+
286
+ // Normalize every timestamp in a gog JSON response. Non-JSON output (plain-text
287
+ // errors, `--plain` results) passes through untouched, as does JSON that is not
288
+ // an object/array, so this can sit on the single response seam safely.
289
+ export function normalizeTimestamps(
290
+ text: string,
291
+ tz = displayTimeZone(),
292
+ naiveTz = naiveSourceTimeZone(),
293
+ ): string {
294
+ const trimmed = text.trim();
295
+ if (trimmed === '' || !/^[[{]/.test(trimmed)) return text;
296
+ let parsed: unknown;
297
+ try {
298
+ parsed = JSON.parse(trimmed);
299
+ } catch {
300
+ return text;
301
+ }
302
+ // The `[`/`{` guard above means anything that parses here is an object or an
303
+ // array, so `walk` always has something to descend into.
304
+ //
305
+ // When nothing was rewritten, return the ORIGINAL text byte-for-byte rather
306
+ // than a re-serialization. Round-tripping through JSON.parse/stringify is not
307
+ // lossless — it drops the caller's --pretty formatting and reorders nothing
308
+ // but reformats everything — and there is no reason to pay that on a response
309
+ // that carries no timestamps at all.
310
+ if (!walk(parsed, tz, naiveTz)) return text;
311
+ return JSON.stringify(parsed, null, detectIndent(text));
312
+ }
@@ -221,14 +221,24 @@ export function registerDriveTools(server: McpServer): void {
221
221
  const { name, mimeType } = fileMeta(await run(['drive', 'get', fileId], { account }));
222
222
  const params = JSON.stringify({ fileId, alt: 'media' });
223
223
  const blob = await runBinary(['api', 'call', 'drive', 'v3', 'files.get', `--params=${params}`], { account });
224
+ const type = mimeType ?? 'application/octet-stream';
225
+ // Several hosts (claude.ai among them) render embedded IMAGE resources
226
+ // and reject every other type outright — "Resources of type
227
+ // 'application/pdf' are not currently supported". The fetch succeeded and
228
+ // the bytes are right here, but the caller sees only the text block, so
229
+ // that text has to carry the way out rather than end on "resource below".
230
+ const unrenderableHint = type.startsWith('image/')
231
+ ? ''
232
+ : ` If your client does not render a ${type} resource, gog_drive_extract_text`
233
+ + ' returns the same file as text (PDFs included, via OCR).';
224
234
  return {
225
235
  content: [
226
- { type: 'text', text: `${name ?? fileId} (${mimeType ?? 'application/octet-stream'}) — ${Buffer.from(blob, 'base64').length} bytes, base64 resource below.` },
236
+ { type: 'text', text: `${name ?? fileId} (${type}) — ${Buffer.from(blob, 'base64').length} bytes, base64 resource below.${unrenderableHint}` },
227
237
  {
228
238
  type: 'resource',
229
239
  resource: {
230
240
  uri: `gogdrive://${fileId}/${encodeURIComponent(name ?? 'file')}`,
231
- mimeType: mimeType ?? 'application/octet-stream',
241
+ mimeType: type,
232
242
  blob,
233
243
  },
234
244
  },
@@ -4,6 +4,7 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
4
4
  import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
5
5
  import { run } from '../runner.js';
6
6
  import type { GogArg } from '../runner.js';
7
+ import { normalizeTimestamps } from '../timestamps.js';
7
8
 
8
9
  // Byte size at or below which a payload stays on the plain inline flag.
9
10
  //
@@ -228,10 +229,21 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
228
229
 
229
230
  export async function runOrDiagnose(
230
231
  args: GogArg[],
231
- options: { account?: string },
232
+ options: { account?: string; lossless?: boolean },
232
233
  ): Promise<CallToolResult> {
233
234
  try {
234
- return rawTextResult(await run(args, options));
235
+ // The single seam every tool's output passes through. Normalizing here —
236
+ // rather than at each call site — is what makes it impossible for a tool to
237
+ // emit a naive, zone-less timestamp.
238
+ //
239
+ // `lossless` opts a tool out. The `*_raw` dumps promise a verbatim copy of
240
+ // the upstream API response: normalizing them would rewrite the API's own
241
+ // epoch-millis `internalDate` into an ISO string and flatten the caller's
242
+ // `--pretty` formatting, so the one tool you reach for when you need ground
243
+ // truth would stop telling it. Losslessness wins over presentation there —
244
+ // the friendlier views of the same data are already normalized.
245
+ const raw = await run(args, options);
246
+ return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
235
247
  } catch (err) {
236
248
  return diagnose(err);
237
249
  }
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, type GogProps } from './connector-auth.js';
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.18.2'; // x-release-please-version
41
+ const VERSION = '2.18.4'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.
@@ -0,0 +1,318 @@
1
+ import { describe, expect, it, afterEach, vi } from 'vitest';
2
+ import {
3
+ DEFAULT_DISPLAY_TZ,
4
+ displayTimeZone,
5
+ formatInstant,
6
+ isNaiveTimestamp,
7
+ naiveSourceTimeZone,
8
+ normalizeTimestamps,
9
+ parseTimestampValue,
10
+ } from '../src/timestamps.js';
11
+
12
+ const ET = 'America/New_York';
13
+
14
+ afterEach(() => {
15
+ vi.unstubAllEnvs();
16
+ });
17
+
18
+ describe('displayTimeZone', () => {
19
+ it('defaults to this deployment’s zone', () => {
20
+ expect(displayTimeZone()).toBe(DEFAULT_DISPLAY_TZ);
21
+ });
22
+
23
+ it('honours DISPLAY_TZ', () => {
24
+ vi.stubEnv('DISPLAY_TZ', 'America/Los_Angeles');
25
+ expect(displayTimeZone()).toBe('America/Los_Angeles');
26
+ });
27
+
28
+ it('falls back when DISPLAY_TZ is not a real IANA zone', () => {
29
+ vi.stubEnv('DISPLAY_TZ', 'Mars/Olympus_Mons');
30
+ expect(displayTimeZone()).toBe(DEFAULT_DISPLAY_TZ);
31
+ });
32
+ });
33
+
34
+ describe('formatInstant', () => {
35
+ // DST correctness comes from the IANA database, not a fixed offset: the same
36
+ // zone is -05:00 in January and -04:00 in July.
37
+ it('renders -05:00 in January and -04:00 in July', () => {
38
+ const jan = formatInstant(new Date('2026-01-15T17:00:00Z'), ET);
39
+ const jul = formatInstant(new Date('2026-07-15T17:00:00Z'), ET);
40
+ expect(jan.iso).toBe('2026-01-15T12:00:00-05:00');
41
+ expect(jul.iso).toBe('2026-07-15T13:00:00-04:00');
42
+ });
43
+
44
+ it('renders a positive offset east of UTC and +00:00 at UTC', () => {
45
+ expect(formatInstant(new Date('2026-07-15T17:00:00Z'), 'Asia/Tokyo').iso)
46
+ .toBe('2026-07-16T02:00:00+09:00');
47
+ expect(formatInstant(new Date('2026-07-15T17:00:00Z'), 'UTC').iso)
48
+ .toBe('2026-07-15T17:00:00+00:00');
49
+ });
50
+
51
+ it('pins midnight to hour 00 rather than 24', () => {
52
+ expect(formatInstant(new Date('2026-07-15T04:00:00Z'), ET).iso)
53
+ .toBe('2026-07-15T00:00:00-04:00');
54
+ });
55
+
56
+ it('handles a zone at a half-hour offset', () => {
57
+ expect(formatInstant(new Date('2026-07-15T00:00:00Z'), 'Asia/Kolkata').iso)
58
+ .toBe('2026-07-15T05:30:00+05:30');
59
+ });
60
+
61
+ it('includes the weekday, which is what makes a date-boundary error visible', () => {
62
+ const { display } = formatInstant(new Date('2026-07-28T03:36:00Z'), ET);
63
+ expect(display).toContain('Mon');
64
+ expect(display).toContain('Jul 27');
65
+ expect(display).toContain('11:36 PM');
66
+ });
67
+ });
68
+
69
+ describe('parseTimestampValue', () => {
70
+ it('treats Gmail internalDate as authoritative epoch milliseconds', () => {
71
+ const instant = parseTimestampValue('internalDate', '1785296160000', ET);
72
+ expect(instant?.toISOString()).toBe(new Date(1785296160000).toISOString());
73
+ });
74
+
75
+ it('interprets a naive wall-clock value in the configured zone', () => {
76
+ const instant = parseTimestampValue('date', '2026-07-27 23:36', ET);
77
+ expect(instant?.toISOString()).toBe('2026-07-28T03:36:00.000Z');
78
+ });
79
+
80
+ it('trusts an offset the source already carries', () => {
81
+ const instant = parseTimestampValue('sentAt', '2026-07-27T23:31:09-04:00', ET);
82
+ expect(instant?.toISOString()).toBe('2026-07-28T03:31:09.000Z');
83
+ });
84
+
85
+ // A bare date is a DATE (Calendar all-day events use it); converting one
86
+ // would invent a time the source never asserted.
87
+ it('leaves a date-only value alone', () => {
88
+ expect(parseTimestampValue('date', '2026-07-28', ET)).toBeNull();
89
+ });
90
+
91
+ it('ignores non-timestamp strings', () => {
92
+ expect(parseTimestampValue('date', 'not a date', ET)).toBeNull();
93
+ });
94
+
95
+ it('ignores a non-string value under a timestamp key', () => {
96
+ expect(parseTimestampValue('updated', 1785296160000, ET)).toBeNull();
97
+ expect(parseTimestampValue('updated', null, ET)).toBeNull();
98
+ });
99
+
100
+ // Shape-matching but not a real date: month 99 satisfies the regex's \d{2}
101
+ // yet Date rejects it. Must not produce an Invalid Date in the payload.
102
+ it('rejects a well-shaped but impossible date', () => {
103
+ expect(parseTimestampValue('sentAt', '2026-99-01T00:00:00Z', ET)).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe('normalizeTimestamps', () => {
108
+ // The reported failure: a 11:36 PM Eastern send read as 03:36 the NEXT day.
109
+ it('reports a late-evening send on the correct calendar day', () => {
110
+ const out = JSON.parse(normalizeTimestamps(
111
+ JSON.stringify({ messages: [{ id: 'm1', date: '2026-07-28 03:36' }] }),
112
+ 'UTC',
113
+ ));
114
+ // Source rendered in UTC; re-read in ET it must land on Jul 27.
115
+ const et = JSON.parse(normalizeTimestamps(
116
+ JSON.stringify({ messages: [{ id: 'm1', internalDate: String(Date.parse('2026-07-28T03:36:00Z')) }] }),
117
+ ET,
118
+ ));
119
+ expect(out.messages[0].date).toMatch(/[+-]\d{2}:\d{2}$|Z$/);
120
+ expect(et.messages[0].internalDate).toBe('2026-07-27T23:36:00-04:00');
121
+ expect(et.messages[0].internalDateDisplay).toContain('Mon, Jul 27');
122
+ });
123
+
124
+ it('never reports a 10:38 PM ET send on the following day', () => {
125
+ const sent = Date.parse('2026-07-28T02:38:00Z'); // 10:38 PM ET on Jul 27
126
+ const out = JSON.parse(normalizeTimestamps(
127
+ JSON.stringify({ internalDate: String(sent) }), ET,
128
+ ));
129
+ expect(out.internalDate.startsWith('2026-07-27')).toBe(true);
130
+ expect(out.internalDateDisplay).toContain('Jul 27');
131
+ });
132
+
133
+ it('adds an explicit offset and a display sibling to every allowlisted field', () => {
134
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
135
+ files: [{ modifiedTime: '2026-07-28T03:36:00Z', createdTime: '2026-07-01T12:00:00Z' }],
136
+ }), ET));
137
+ expect(out.files[0].modifiedTime).toBe('2026-07-27T23:36:00-04:00');
138
+ expect(out.files[0].modifiedTimeDisplay).toContain('Mon, Jul 27');
139
+ expect(out.files[0].createdTimeDisplay).toBeDefined();
140
+ });
141
+
142
+ it('recurses into nested Calendar structures', () => {
143
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
144
+ items: [{ start: { dateTime: '2026-07-28T03:36:00Z', timeZone: 'America/New_York' } }],
145
+ }), ET));
146
+ expect(out.items[0].start.dateTime).toBe('2026-07-27T23:36:00-04:00');
147
+ expect(out.items[0].start.dateTimeDisplay).toContain('Jul 27');
148
+ // A zone NAME is not an instant and must survive untouched.
149
+ expect(out.items[0].start.timeZone).toBe('America/New_York');
150
+ });
151
+
152
+ // The near-miss names are the real hazard: a name-pattern match would
153
+ // rewrite spreadsheet cell data.
154
+ it('leaves near-miss keys and cell values alone', () => {
155
+ const payload = {
156
+ updatedCells: 5,
157
+ updatedRange: 'Sheet1!A1:B2',
158
+ updatedRows: 2,
159
+ formattedValue: '2026-07-28 03:36',
160
+ verificationStatus: 'accepted',
161
+ values: [['2026-07-28 03:36']],
162
+ };
163
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify(payload), ET));
164
+ expect(out).toEqual(payload);
165
+ });
166
+
167
+ it('passes non-JSON output through untouched', () => {
168
+ expect(normalizeTimestamps('Error: something failed', ET)).toBe('Error: something failed');
169
+ expect(normalizeTimestamps('', ET)).toBe('');
170
+ expect(normalizeTimestamps('not json {', ET)).toBe('not json {');
171
+ expect(normalizeTimestamps('"a string"', ET)).toBe('"a string"');
172
+ expect(normalizeTimestamps('{bad json', ET)).toBe('{bad json');
173
+ });
174
+
175
+ it('is idempotent — re-normalizing changes nothing', () => {
176
+ const once = normalizeTimestamps(JSON.stringify({ date: '2026-07-27 23:36' }), ET);
177
+ expect(normalizeTimestamps(once, ET)).toBe(once);
178
+ });
179
+
180
+ // Contract test: nothing emitted may lack an offset or Z.
181
+ it('emits no naive timestamp anywhere in the payload', () => {
182
+ const out = normalizeTimestamps(JSON.stringify({
183
+ a: { date: '2026-07-28 03:36' },
184
+ b: [{ sentAt: '2026-07-27T23:31:09' }],
185
+ c: { fetchedBodyAt: '2026-07-28T12:11:19.106Z' },
186
+ }), ET);
187
+ const parsed = JSON.parse(out);
188
+ const naive: string[] = [];
189
+ const scan = (n: unknown): void => {
190
+ if (Array.isArray(n)) return void n.forEach(scan);
191
+ if (n && typeof n === 'object') return void Object.values(n).forEach(scan);
192
+ if (isNaiveTimestamp(n)) naive.push(String(n));
193
+ };
194
+ scan(parsed);
195
+ expect(naive).toEqual([]);
196
+ });
197
+
198
+ // Mixed-zone assertion: one object must not carry both naive and
199
+ // offset-bearing values.
200
+ it('never mixes naive and offset-bearing timestamps in one object', () => {
201
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
202
+ sentAt: '2026-07-27T23:31:09',
203
+ fetchedBodyAt: '2026-07-28T12:11:19.106Z',
204
+ asOf: '2026-07-28T20:27:11.426Z',
205
+ }), ET));
206
+ const values = [out.sentAt, out.fetchedBodyAt, out.asOf];
207
+ expect(values.every((v: string) => /([+-]\d{2}:\d{2}|Z)$/.test(v))).toBe(true);
208
+ expect(values.some(isNaiveTimestamp)).toBe(false);
209
+ });
210
+
211
+ it('keeps contemporaneous events in order and on the same day', () => {
212
+ const base = Date.parse('2026-07-28T03:31:09Z');
213
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({
214
+ ofw: { sentAt: '2026-07-27T23:31:09' },
215
+ gmail: { internalDate: String(base + 5 * 60_000) },
216
+ }), ET));
217
+ expect(out.ofw.sentAt).toBe('2026-07-27T23:31:09-04:00');
218
+ expect(out.gmail.internalDate).toBe('2026-07-27T23:36:09-04:00');
219
+ expect(out.ofw.sentAtDisplay).toContain('Jul 27');
220
+ expect(out.gmail.internalDateDisplay).toContain('Jul 27');
221
+ });
222
+
223
+ it('DISPLAY_TZ shifts display fields and the offset, nothing else', () => {
224
+ const payload = JSON.stringify({ id: 'm1', subject: 'S', internalDate: '1785296160000' });
225
+ const et = JSON.parse(normalizeTimestamps(payload, ET));
226
+ const pt = JSON.parse(normalizeTimestamps(payload, 'America/Los_Angeles'));
227
+ expect(et.internalDate).not.toBe(pt.internalDate);
228
+ expect(et.internalDateDisplay).not.toBe(pt.internalDateDisplay);
229
+ // Same instant either way.
230
+ expect(Date.parse(et.internalDate)).toBe(Date.parse(pt.internalDate));
231
+ // Non-timestamp fields are untouched by the zone.
232
+ expect(pt.id).toBe('m1');
233
+ expect(pt.subject).toBe('S');
234
+ });
235
+
236
+ // In UTC, longOffset renders a bare "GMT" with no numeric part, and a naive
237
+ // wall time needs no correction at all.
238
+ it('renders UTC as +00:00 with no drift correction', () => {
239
+ const out = JSON.parse(normalizeTimestamps(
240
+ JSON.stringify({ date: '2026-07-28 03:36' }), 'UTC', 'UTC',
241
+ ));
242
+ expect(out.date).toBe('2026-07-28T03:36:00+00:00');
243
+ expect(out.dateDisplay).toContain('Jul 28');
244
+ });
245
+
246
+ it('preserves sub-second precision on a naive value', () => {
247
+ const out = JSON.parse(normalizeTimestamps(
248
+ JSON.stringify({ fetchedBodyAt: '2026-07-28T12:11:19.106' }), 'UTC', 'UTC',
249
+ ));
250
+ expect(out.fetchedBodyAt).toBe('2026-07-28T12:11:19.106+00:00');
251
+ });
252
+
253
+ it('normalizes a top-level array', () => {
254
+ const out = JSON.parse(normalizeTimestamps(
255
+ JSON.stringify([{ internalDate: '1785296160000' }]), ET,
256
+ ));
257
+ expect(out[0].internalDate).toMatch(/[+-]\d{2}:\d{2}$/);
258
+ });
259
+
260
+ it('leaves an allowlisted key alone when its value is not a timestamp', () => {
261
+ const payload = { date: 'sometime last week', updated: '' };
262
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify(payload), ET));
263
+ expect(out).toEqual(payload);
264
+ });
265
+
266
+ // Re-serializing a response that carries no timestamps would silently reflow
267
+ // it — including flattening a caller's --pretty formatting.
268
+ it('returns the original text byte-for-byte when nothing was rewritten', () => {
269
+ const pretty = '{\n "id": "m1",\n "subject": "S"\n}';
270
+ expect(normalizeTimestamps(pretty, ET)).toBe(pretty);
271
+ });
272
+
273
+ it('preserves the caller’s pretty indentation when it does rewrite', () => {
274
+ const pretty = '{\n "id": "m1",\n "date": "2026-07-27 23:36"\n}';
275
+ const out = normalizeTimestamps(pretty, ET);
276
+ expect(out).toContain('\n "date"');
277
+ expect(JSON.parse(out).date).toBe('2026-07-27T23:36:00-04:00');
278
+ });
279
+
280
+ // Date.UTC rolls impossible components over instead of rejecting them, so a
281
+ // typo would surface as a confident wrong date.
282
+ it('rejects impossible naive dates rather than rolling them over', () => {
283
+ for (const bad of ['2026-13-05T10:00:00', '2026-02-30T10:00:00', '2026-01-01T25:00:00']) {
284
+ expect(parseTimestampValue('date', bad, ET)).toBeNull();
285
+ }
286
+ });
287
+
288
+ // 10 digits is epoch SECONDS; reading it as milliseconds dates it to 1970.
289
+ it('only treats a 13-digit internalDate as epoch milliseconds', () => {
290
+ expect(parseTimestampValue('internalDate', '1785209760', ET)).toBeNull();
291
+ expect(parseTimestampValue('internalDate', '1785209760000', ET)).not.toBeNull();
292
+ });
293
+
294
+ it('reads the naive-source zone from GOG_TIMEZONE, independent of DISPLAY_TZ', () => {
295
+ vi.stubEnv('GOG_TIMEZONE', 'UTC');
296
+ vi.stubEnv('DISPLAY_TZ', 'America/New_York');
297
+ // gog formatted this in UTC; it must be read as UTC and displayed in ET.
298
+ const out = JSON.parse(normalizeTimestamps(JSON.stringify({ date: '2026-07-28 03:36' })));
299
+ expect(out.date).toBe('2026-07-27T23:36:00-04:00');
300
+ expect(out.dateDisplay).toContain('Jul 27');
301
+ });
302
+
303
+ it('naiveSourceTimeZone falls back to the display zone', () => {
304
+ vi.stubEnv('DISPLAY_TZ', 'America/Los_Angeles');
305
+ expect(naiveSourceTimeZone()).toBe('America/Los_Angeles');
306
+ vi.stubEnv('GOG_TIMEZONE', 'Mars/Olympus_Mons');
307
+ expect(naiveSourceTimeZone()).toBe('America/Los_Angeles');
308
+ });
309
+
310
+ it('handles a DST spring-forward wall time without drifting a day', () => {
311
+ // 2026-03-08 02:30 ET does not exist (clocks jump 02:00 -> 03:00).
312
+ const out = JSON.parse(normalizeTimestamps(
313
+ JSON.stringify({ date: '2026-03-08 02:30' }), ET,
314
+ ));
315
+ expect(out.date.startsWith('2026-03-08')).toBe(true);
316
+ expect(out.date).toMatch(/[+-]\d{2}:\d{2}$/);
317
+ });
318
+ });
@@ -371,6 +371,25 @@ describe('gog_drive_read_bytes', () => {
371
371
  );
372
372
  });
373
373
 
374
+ it('names the text fallback when the resource type is one hosts refuse to render', async () => {
375
+ // The bytes arrive fine; some hosts just drop a non-image embedded resource
376
+ // ("Resources of type 'application/pdf' are not currently supported"), and
377
+ // then the text block is all the caller gets.
378
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.pdf', mimeType: 'application/pdf' } }));
379
+ vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('%PDF-1.4').toString('base64'));
380
+ const harness = await setupHandlers();
381
+ const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f1' });
382
+ expect(result.content[0].text).toContain('gog_drive_extract_text');
383
+ });
384
+
385
+ it('does not add the fallback hint for an image, which hosts do render', async () => {
386
+ vi.mocked(runner.run).mockResolvedValueOnce(JSON.stringify({ file: { name: 'a.png', mimeType: 'image/png' } }));
387
+ vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
388
+ const harness = await setupHandlers();
389
+ const result = await harness.callTool('gog_drive_read_bytes', { fileId: 'f3' });
390
+ expect(result.content[0].text).not.toContain('gog_drive_extract_text');
391
+ });
392
+
374
393
  it('falls back to octet-stream and "file" when metadata lacks name/mime', async () => {
375
394
  vi.mocked(runner.run).mockResolvedValueOnce('{}');
376
395
  vi.mocked(runner.runBinary).mockResolvedValueOnce(Buffer.from('x').toString('base64'));
@@ -95,6 +95,25 @@ describe('runOrDiagnose', () => {
95
95
  expect(result.isError).toBeUndefined();
96
96
  });
97
97
 
98
+ // The `*_raw` dumps promise a verbatim copy of the upstream API response.
99
+ // Normalizing them would rewrite the API's own epoch-millis internalDate into
100
+ // an ISO string and flatten the caller's --pretty formatting, so the one tool
101
+ // you reach for when you need ground truth would stop telling it.
102
+ it('leaves a lossless response byte-for-byte untouched', async () => {
103
+ const raw = '{\n "id": "m1",\n "internalDate": "1785209760000"\n}';
104
+ vi.mocked(runner.run).mockResolvedValue(raw);
105
+ const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
106
+ expect(result.content[0].text).toBe(raw);
107
+ });
108
+
109
+ it('normalizes timestamps when lossless is not set', async () => {
110
+ vi.mocked(runner.run).mockResolvedValue('{"internalDate":"1785209760000"}');
111
+ const result = await runOrDiagnose(['gmail', 'messages'], {});
112
+ const parsed = JSON.parse(result.content[0].text as string);
113
+ expect(parsed.internalDate).toMatch(/[+-]\d{2}:\d{2}$/);
114
+ expect(parsed.internalDateDisplay).toBeDefined();
115
+ });
116
+
98
117
  it('appends auth list on non-auth failure', async () => {
99
118
  vi.mocked(runner.run)
100
119
  .mockRejectedValueOnce(new Error('Doc not found'))