gogcli-mcp 2.18.3 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3",
6
+ "version": "2.19.0",
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.3",
3
+ "version": "2.19.0",
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>",
@@ -46,7 +46,7 @@
46
46
  "zod": "^4.4.3"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.1.1",
49
+ "@types/node": "^26.1.2",
50
50
  "@vitest/coverage-v8": "^4.1.8",
51
51
  "esbuild": "^0.28.1",
52
52
  "typescript": "^7.0.2",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.18.3",
10
+ "version": "2.19.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.18.3",
15
+ "version": "2.19.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
package/src/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
3
  import { BASE_TOOL_REGISTRARS, VERSION } from './server.js';
4
+ import { useRemoteGogRunner } from './remote-runner.js';
5
+
6
+
7
+ // Execute `gog` on the Fly backend when the host points us at one; without
8
+ // it, nothing changes and we spawn the local binary as before.
9
+ useRemoteGogRunner();
4
10
 
5
11
  await runMcp({
6
12
  name: 'gogcli',
package/src/lib.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  registerTasksTools,
16
16
  } from './server.js';
17
17
  export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
18
+ export { useRemoteGogRunner } from './remote-runner.js';
18
19
  export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
19
20
  export {
20
21
  PAYLOAD_INLINE_MAX,
@@ -0,0 +1,49 @@
1
+ import { readEnvVar } from '@chrischall/mcp-utils';
2
+ import { runExecutor } from './runner.js';
3
+ import { makeFlyExecutor } from './connector-runtime.js';
4
+
5
+ /**
6
+ * Let a stdio server run `gog` on the Fly backend instead of spawning it.
7
+ *
8
+ * The default stdio path shells out to the `gog` binary (`runner.ts`,
9
+ * `spawn(GOG_PATH ?? 'gog')`), which is right on a laptop and impossible
10
+ * anywhere the binary is not installed — notably mcp-host, whose runner image
11
+ * is deliberately Node + git + tar and nothing else. Baking a Go binary into a
12
+ * generic runner for one MCP's sake, or curling an unpinned release tarball
13
+ * inside an install, are both worse than using the seam that already exists:
14
+ * `makeFlyExecutor` has forwarded arg-arrays to `<runner>/run` for the
15
+ * Cloudflare connector since that connector shipped, and it touches nothing
16
+ * Worker-only.
17
+ *
18
+ * So this is wiring, not new machinery. Set both variables and the process
19
+ * executes remotely; leave either unset and nothing changes, which is what
20
+ * keeps every existing local install on the binary it already has.
21
+ *
22
+ * ## Why `enterWith` and not `run`
23
+ *
24
+ * `runExecutor` is an AsyncLocalStorage. The Worker wraps each REQUEST in
25
+ * `runExecutor.run(...)` because a Worker isolate serves many of them and the
26
+ * executor differs per user. A stdio process is one user for its whole life,
27
+ * and its tool calls arrive later as I/O callbacks — which would NOT inherit a
28
+ * store established by a `run()` that had already returned. `enterWith` sets it
29
+ * for the remainder of this execution and everything descending from it, which
30
+ * is the whole process. Using `run()` here would look correct and then fall
31
+ * back to spawning on the first actual tool call.
32
+ *
33
+ * Call before the server starts, so no tool can be serviced ahead of it.
34
+ */
35
+ export function useRemoteGogRunner(env: NodeJS.ProcessEnv = process.env): boolean {
36
+ // The shared reader, not a local trim: it already treats blanks, unexpanded
37
+ // `${...}` placeholders AND the literal strings "undefined"/"null" as unset.
38
+ // Those last two are what a hand-rolled check misses, and they arrive whenever
39
+ // a host stringifies a missing value into an env block.
40
+ const endpoint = readEnvVar('GOG_RUNNER_URL', { env });
41
+ const key = readEnvVar('GOG_RUNNER_KEY', { env });
42
+ // Both or neither. A URL with no key would send unauthenticated requests the
43
+ // runner rejects, and a key with no URL is a credential configured for
44
+ // nothing — either alone is a misconfiguration, and silently spawning
45
+ // instead would hide it until someone wondered why the binary was needed.
46
+ if (!endpoint || !key) return false;
47
+ runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ''), key) });
48
+ return true;
49
+ }
@@ -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
+ }
@@ -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.3'; // x-release-please-version
41
+ const VERSION = '2.19.0'; // 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,53 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { useRemoteGogRunner } from '../src/remote-runner.js';
3
+ import { runExecutor } from '../src/runner.js';
4
+
5
+ /**
6
+ * The whole point of this seam is that a host without the `gog` binary can
7
+ * still serve. Two ways it silently fails: a half-configured env that falls
8
+ * back to spawning, and using `run()` instead of `enterWith()` so the store is
9
+ * gone by the time a tool call arrives.
10
+ */
11
+
12
+ afterEach(() => vi.unstubAllGlobals());
13
+
14
+ describe('useRemoteGogRunner', () => {
15
+ it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
16
+ expect(useRemoteGogRunner({})).toBe(false);
17
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test' })).toBe(false);
18
+ expect(useRemoteGogRunner({ GOG_RUNNER_KEY: 'k' })).toBe(false);
19
+ });
20
+
21
+ it('treats blanks, placeholders and stringified nothings as unset', () => {
22
+ // MCP hosts pass env blocks through verbatim, so all three of these arrive
23
+ // in practice: a blank, a literal `${...}` that never expanded, and the
24
+ // string "undefined" from a host that stringified a missing value. The
25
+ // shared readEnvVar knows all three; a hand-rolled trim knew only the first
26
+ // two, which is why this uses the shared one.
27
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: ' ', GOG_RUNNER_KEY: 'k' })).toBe(false);
28
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: '${GOG_RUNNER_KEY}' })).toBe(false);
29
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'undefined' })).toBe(false);
30
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
31
+ });
32
+
33
+ it('installs an executor that survives into a LATER async callback', async () => {
34
+ // The real failure mode this guards: with `run()` the store would be gone
35
+ // by the time a tool call arrives as an I/O callback, and the server would
36
+ // quietly go back to spawning a binary that is not installed.
37
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
38
+ vi.stubGlobal('fetch', fetchMock);
39
+
40
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
41
+
42
+ // Cross a macrotask boundary, the way a stdio tool call does.
43
+ await new Promise((r) => setTimeout(r, 0));
44
+ const store = runExecutor.getStore();
45
+ expect(store?.executor).toBeTypeOf('function');
46
+
47
+ await store!.executor(['--version'], {});
48
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
49
+ // Trailing slash trimmed, so the endpoint is never `…//run`.
50
+ expect(url).toBe('https://r.test/run');
51
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
52
+ });
53
+ });