gogcli-mcp 2.18.4 → 2.19.1

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.4"
10
+ "version": "2.19.1"
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.4",
18
+ "version": "2.19.1",
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.4",
4
+ "version": "2.19.1",
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
@@ -3644,7 +3644,12 @@ var require_fast_uri = __commonJS({
3644
3644
  }
3645
3645
  function resolve(baseURI, relativeURI, options) {
3646
3646
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3647
- const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3647
+ const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3648
+ const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
3649
+ if (baseMalformed || relativeMalformed) {
3650
+ throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
3651
+ }
3652
+ const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
3648
3653
  schemelessOptions.skipEscape = true;
3649
3654
  return serialize(resolved, schemelessOptions);
3650
3655
  }
@@ -3770,6 +3775,7 @@ var require_fast_uri = __commonJS({
3770
3775
  }
3771
3776
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3772
3777
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3778
+ var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
3773
3779
  function getParseError(parsed, matches) {
3774
3780
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3775
3781
  return 'URI path must start with "/" when authority is present.';
@@ -3804,6 +3810,20 @@ var require_fast_uri = __commonJS({
3804
3810
  parsed.error = "URI authority must not contain a literal backslash.";
3805
3811
  malformedAuthorityOrPort = true;
3806
3812
  }
3813
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
3814
+ if (introducerMatch !== null) {
3815
+ const region = introducerMatch[1];
3816
+ const normalizedRegion = region.replace(/[\t\n\r]/g, "");
3817
+ if (normalizedRegion.length >= 2) {
3818
+ if (normalizedRegion.slice(0, 2) !== "//") {
3819
+ parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
3820
+ malformedAuthorityOrPort = true;
3821
+ } else if (region.length !== normalizedRegion.length) {
3822
+ parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
3823
+ malformedAuthorityOrPort = true;
3824
+ }
3825
+ }
3826
+ }
3807
3827
  const matches = uri.match(URI_PARSE);
3808
3828
  if (matches) {
3809
3829
  parsed.scheme = matches[1];
@@ -33052,7 +33072,7 @@ function registerTasksTools(server) {
33052
33072
  }
33053
33073
 
33054
33074
  // src/server.ts
33055
- var VERSION = true ? "2.18.4" : "0.0.0";
33075
+ var VERSION = true ? "2.19.1" : "0.0.0";
33056
33076
  var BASE_TOOL_REGISTRARS = [
33057
33077
  registerApiTools,
33058
33078
  registerAuthTools,
@@ -33067,7 +33087,69 @@ var BASE_TOOL_REGISTRARS = [
33067
33087
  registerTasksTools
33068
33088
  ];
33069
33089
 
33090
+ // src/connector-runtime.ts
33091
+ var DEFAULT_TIMEOUT_MS = 3e4;
33092
+ var DEADLINE_GRACE_MS = 5e3;
33093
+ var RUNNER_GOG_FAILED = 422;
33094
+ var RUNNER_DRAINING = 503;
33095
+ function makeFlyExecutor(endpoint, key) {
33096
+ return async (args, opts) => {
33097
+ const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
33098
+ let res;
33099
+ try {
33100
+ res = await fetch(endpoint + "/run", {
33101
+ method: "POST",
33102
+ headers: {
33103
+ Authorization: "Bearer " + key,
33104
+ "Content-Type": "application/json"
33105
+ },
33106
+ body: JSON.stringify({ args }),
33107
+ signal: AbortSignal.timeout(deadlineMs)
33108
+ });
33109
+ } catch (err) {
33110
+ const name = err instanceof Error ? err.name : "";
33111
+ if (name === "TimeoutError" || name === "AbortError") {
33112
+ throw new Error(
33113
+ `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) \u2014 the Fly backend may be cold or wedged`
33114
+ );
33115
+ }
33116
+ throw err;
33117
+ }
33118
+ if (!res.ok) {
33119
+ const body = await res.json().catch(() => null);
33120
+ const detail = body && typeof body.error === "string" ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim() ? `${body.error}
33121
+ ${body.stderr}` : body.error : "";
33122
+ if (res.status === RUNNER_GOG_FAILED) {
33123
+ throw new Error(detail || "gog failed on the runner (no detail supplied)");
33124
+ }
33125
+ if (res.status === RUNNER_DRAINING || body?.retryable === true) {
33126
+ throw new Error(
33127
+ `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ""}`
33128
+ );
33129
+ }
33130
+ if (detail) {
33131
+ throw new Error(detail);
33132
+ }
33133
+ throw new Error(
33134
+ `gog-runner HTTP ${res.status}: the response did not come from the runner, so the request never reached gog. The backend Machine was most likely starting or shutting down \u2014 this is transient, retry the same call.`
33135
+ );
33136
+ }
33137
+ const { stdout } = await res.json();
33138
+ return stdout;
33139
+ };
33140
+ }
33141
+
33142
+ // src/remote-runner.ts
33143
+ function useRemoteGogRunner(env = process.env) {
33144
+ const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
33145
+ const key = readEnvVar("GOG_RUNNER_KEY", { env });
33146
+ if (!endpoint || !key) return false;
33147
+ runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
33148
+ return true;
33149
+ }
33150
+
33070
33151
  // src/index.ts
33152
+ useRemoteGogRunner();
33071
33153
  await runMcp({
33072
33154
  name: "gogcli",
33073
33155
  version: VERSION,
package/dist/lib.js CHANGED
@@ -3643,7 +3643,12 @@ var require_fast_uri = __commonJS({
3643
3643
  }
3644
3644
  function resolve(baseURI, relativeURI, options) {
3645
3645
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3646
- const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3646
+ const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions);
3647
+ const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions);
3648
+ if (baseMalformed || relativeMalformed) {
3649
+ throw new Error(baseParsed.error || relativeParsed.error || "URI is malformed.");
3650
+ }
3651
+ const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true);
3647
3652
  schemelessOptions.skipEscape = true;
3648
3653
  return serialize(resolved, schemelessOptions);
3649
3654
  }
@@ -3769,6 +3774,7 @@ var require_fast_uri = __commonJS({
3769
3774
  }
3770
3775
  var URI_PARSE = /^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;
3771
3776
  var AUTHORITY_PREFIX = /^(?:[^#/:?]+:)?\/\/([^/?#]*)/;
3777
+ var AUTHORITY_INTRODUCER_REGION = /^(?:[^#/:?]+:)?([/\\\t\n\r]*)/;
3772
3778
  function getParseError(parsed, matches) {
3773
3779
  if (matches[2] !== void 0 && parsed.path && parsed.path[0] !== "/") {
3774
3780
  return 'URI path must start with "/" when authority is present.';
@@ -3803,6 +3809,20 @@ var require_fast_uri = __commonJS({
3803
3809
  parsed.error = "URI authority must not contain a literal backslash.";
3804
3810
  malformedAuthorityOrPort = true;
3805
3811
  }
3812
+ const introducerMatch = uri.match(AUTHORITY_INTRODUCER_REGION);
3813
+ if (introducerMatch !== null) {
3814
+ const region = introducerMatch[1];
3815
+ const normalizedRegion = region.replace(/[\t\n\r]/g, "");
3816
+ if (normalizedRegion.length >= 2) {
3817
+ if (normalizedRegion.slice(0, 2) !== "//") {
3818
+ parsed.error = parsed.error || "URI authority must not contain a literal backslash.";
3819
+ malformedAuthorityOrPort = true;
3820
+ } else if (region.length !== normalizedRegion.length) {
3821
+ parsed.error = parsed.error || "URI authority introducer must not contain whitespace.";
3822
+ malformedAuthorityOrPort = true;
3823
+ }
3824
+ }
3825
+ }
3806
3826
  const matches = uri.match(URI_PARSE);
3807
3827
  if (matches) {
3808
3828
  parsed.scheme = matches[1];
@@ -24958,7 +24978,7 @@ function registerTasksTools(server) {
24958
24978
  }
24959
24979
 
24960
24980
  // src/server.ts
24961
- var VERSION = true ? "2.18.4" : "0.0.0";
24981
+ var VERSION = true ? "2.19.1" : "0.0.0";
24962
24982
  var BASE_TOOL_REGISTRARS = [
24963
24983
  registerApiTools,
24964
24984
  registerAuthTools,
@@ -24972,6 +24992,67 @@ var BASE_TOOL_REGISTRARS = [
24972
24992
  registerSlidesTools,
24973
24993
  registerTasksTools
24974
24994
  ];
24995
+
24996
+ // src/connector-runtime.ts
24997
+ var DEFAULT_TIMEOUT_MS = 3e4;
24998
+ var DEADLINE_GRACE_MS = 5e3;
24999
+ var RUNNER_GOG_FAILED = 422;
25000
+ var RUNNER_DRAINING = 503;
25001
+ function makeFlyExecutor(endpoint, key) {
25002
+ return async (args, opts) => {
25003
+ const deadlineMs = (opts?.timeout ?? DEFAULT_TIMEOUT_MS) + DEADLINE_GRACE_MS;
25004
+ let res;
25005
+ try {
25006
+ res = await fetch(endpoint + "/run", {
25007
+ method: "POST",
25008
+ headers: {
25009
+ Authorization: "Bearer " + key,
25010
+ "Content-Type": "application/json"
25011
+ },
25012
+ body: JSON.stringify({ args }),
25013
+ signal: AbortSignal.timeout(deadlineMs)
25014
+ });
25015
+ } catch (err) {
25016
+ const name = err instanceof Error ? err.name : "";
25017
+ if (name === "TimeoutError" || name === "AbortError") {
25018
+ throw new Error(
25019
+ `gog-runner did not respond within ${deadlineMs}ms (${endpoint}) \u2014 the Fly backend may be cold or wedged`
25020
+ );
25021
+ }
25022
+ throw err;
25023
+ }
25024
+ if (!res.ok) {
25025
+ const body = await res.json().catch(() => null);
25026
+ const detail = body && typeof body.error === "string" ? body.stderr && body.stderr.trim() && body.stderr.trim() !== body.error.trim() ? `${body.error}
25027
+ ${body.stderr}` : body.error : "";
25028
+ if (res.status === RUNNER_GOG_FAILED) {
25029
+ throw new Error(detail || "gog failed on the runner (no detail supplied)");
25030
+ }
25031
+ if (res.status === RUNNER_DRAINING || body?.retryable === true) {
25032
+ throw new Error(
25033
+ `gog-runner is restarting; retry this call.${detail ? ` ${detail}` : ""}`
25034
+ );
25035
+ }
25036
+ if (detail) {
25037
+ throw new Error(detail);
25038
+ }
25039
+ throw new Error(
25040
+ `gog-runner HTTP ${res.status}: the response did not come from the runner, so the request never reached gog. The backend Machine was most likely starting or shutting down \u2014 this is transient, retry the same call.`
25041
+ );
25042
+ }
25043
+ const { stdout } = await res.json();
25044
+ return stdout;
25045
+ };
25046
+ }
25047
+
25048
+ // src/remote-runner.ts
25049
+ function useRemoteGogRunner(env = process.env) {
25050
+ const endpoint = readEnvVar("GOG_RUNNER_URL", { env });
25051
+ const key = readEnvVar("GOG_RUNNER_KEY", { env });
25052
+ if (!endpoint || !key) return false;
25053
+ runExecutor.enterWith({ executor: makeFlyExecutor(endpoint.replace(/\/+$/, ""), key) });
25054
+ return true;
25055
+ }
24975
25056
  export {
24976
25057
  BASE_TOOL_REGISTRARS,
24977
25058
  MIN_GOG_VERSION,
@@ -25001,5 +25082,6 @@ export {
25001
25082
  run,
25002
25083
  runBinary,
25003
25084
  runExecutor,
25004
- runOrDiagnose
25085
+ runOrDiagnose,
25086
+ useRemoteGogRunner
25005
25087
  };
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.4",
6
+ "version": "2.19.1",
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.4",
3
+ "version": "2.19.1",
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.4",
10
+ "version": "2.19.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.18.4",
15
+ "version": "2.19.1",
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
+ }
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.4'; // x-release-please-version
41
+ const VERSION = '2.19.1'; // 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,90 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { describe, it, expect, vi, afterEach } from 'vitest';
5
+ import { useRemoteGogRunner } from '../src/remote-runner.js';
6
+ import { runExecutor } from '../src/runner.js';
7
+
8
+ /**
9
+ * The whole point of this seam is that a host without the `gog` binary can
10
+ * still serve. Two ways it silently fails: a half-configured env that falls
11
+ * back to spawning, and using `run()` instead of `enterWith()` so the store is
12
+ * gone by the time a tool call arrives.
13
+ */
14
+
15
+ afterEach(() => vi.unstubAllGlobals());
16
+
17
+ describe('useRemoteGogRunner', () => {
18
+ it('does nothing unless BOTH variables are set, so local installs are untouched', () => {
19
+ expect(useRemoteGogRunner({})).toBe(false);
20
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test' })).toBe(false);
21
+ expect(useRemoteGogRunner({ GOG_RUNNER_KEY: 'k' })).toBe(false);
22
+ });
23
+
24
+ it('treats blanks, placeholders and stringified nothings as unset', () => {
25
+ // MCP hosts pass env blocks through verbatim, so all three of these arrive
26
+ // in practice: a blank, a literal `${...}` that never expanded, and the
27
+ // string "undefined" from a host that stringified a missing value. The
28
+ // shared readEnvVar knows all three; a hand-rolled trim knew only the first
29
+ // two, which is why this uses the shared one.
30
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: ' ', GOG_RUNNER_KEY: 'k' })).toBe(false);
31
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: '${GOG_RUNNER_KEY}' })).toBe(false);
32
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test', GOG_RUNNER_KEY: 'undefined' })).toBe(false);
33
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'null', GOG_RUNNER_KEY: 'k' })).toBe(false);
34
+ });
35
+
36
+ it('installs an executor that survives into a LATER async callback', async () => {
37
+ // The real failure mode this guards: with `run()` the store would be gone
38
+ // by the time a tool call arrives as an I/O callback, and the server would
39
+ // quietly go back to spawning a binary that is not installed.
40
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({ stdout: 'ok' }), { status: 200 }));
41
+ vi.stubGlobal('fetch', fetchMock);
42
+
43
+ expect(useRemoteGogRunner({ GOG_RUNNER_URL: 'https://r.test/', GOG_RUNNER_KEY: 'secret' })).toBe(true);
44
+
45
+ // Cross a macrotask boundary, the way a stdio tool call does.
46
+ await new Promise((r) => setTimeout(r, 0));
47
+ const store = runExecutor.getStore();
48
+ expect(store?.executor).toBeTypeOf('function');
49
+
50
+ await store!.executor(['--version'], {});
51
+ const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
52
+ // Trailing slash trimmed, so the endpoint is never `…//run`.
53
+ expect(url).toBe('https://r.test/run');
54
+ expect((init.headers as Record<string, string>).Authorization).toBe('Bearer secret');
55
+ });
56
+ });
57
+
58
+ /**
59
+ * The seam is opt-in PER BIN — `useRemoteGogRunner()` is a call in each
60
+ * package's `index.ts`, and a package that omits it spawns the binary no
61
+ * matter what the host sets. That shipped: 2.19.0 wired base, sheets, docs,
62
+ * drive and gmail, and left calendar, classroom, contacts and slides behind,
63
+ * so those four answered every tool call on mcp-host with "gog executable not
64
+ * found" — a host with no binary and no way to ask for the backend.
65
+ *
66
+ * Nothing caught it because each package's suite covers its TOOLS, and
67
+ * `src/index.ts` is excluded from the coverage gate in every package (it is
68
+ * the bin: it boots a server and cannot be imported under test). So this
69
+ * asserts on the source text instead, across the whole workspace — the one
70
+ * check that scales to the next sub-package, which will otherwise be added by
71
+ * copying an index.ts that predates the seam.
72
+ */
73
+ describe('every package bin', () => {
74
+ const packagesDir = fileURLToPath(new URL('../../', import.meta.url));
75
+ const bins = readdirSync(packagesDir)
76
+ .filter((name) => name.startsWith('gogcli-mcp'))
77
+ .map((name) => [name, join(packagesDir, name, 'src', 'index.ts')] as const);
78
+
79
+ it('finds every package (guards the glob itself against silently matching nothing)', () => {
80
+ expect(bins.length).toBeGreaterThanOrEqual(9);
81
+ });
82
+
83
+ it.each(bins)('%s installs the remote executor before starting the server', (_name, path) => {
84
+ const source = readFileSync(path, 'utf8');
85
+ expect(source).toContain('useRemoteGogRunner()');
86
+ // Order matters as much as presence: `runMcp` starts serving, so a call
87
+ // placed after it could be beaten by a tool call and fall back to spawning.
88
+ expect(source.indexOf('useRemoteGogRunner()')).toBeLessThan(source.indexOf('runMcp({'));
89
+ });
90
+ });