gogcli-mcp 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
10
- "version": "4.1.0"
10
+ "version": "4.2.0"
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": "4.1.0",
18
+ "version": "4.2.0",
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": "4.1.0",
4
+ "version": "4.2.0",
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
@@ -5,6 +5,32 @@ var __export = (target, all) => {
5
5
  __defProp(target, name, { get: all[name], enumerable: true });
6
6
  };
7
7
 
8
+ // ../../node_modules/@chrischall/mcp-utils/dist/cancel/index.js
9
+ import { AsyncLocalStorage } from "node:async_hooks";
10
+ var storage = new AsyncLocalStorage();
11
+ function withCallSignal(signal, fn) {
12
+ return signal ? storage.run({ signal }, fn) : fn();
13
+ }
14
+ function currentCallSignal() {
15
+ return storage.getStore()?.signal;
16
+ }
17
+ function killOnCancel(child, signal = "SIGTERM") {
18
+ const ambient = currentCallSignal();
19
+ if (!ambient)
20
+ return () => {
21
+ };
22
+ if (ambient.aborted) {
23
+ child.kill(signal);
24
+ return () => {
25
+ };
26
+ }
27
+ const onAbort = () => {
28
+ child.kill(signal);
29
+ };
30
+ ambient.addEventListener("abort", onAbort, { once: true });
31
+ return () => ambient.removeEventListener("abort", onAbort);
32
+ }
33
+
8
34
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
9
35
  var __create = Object.create;
10
36
  var __defProp2 = Object.defineProperty;
@@ -34526,17 +34552,41 @@ Hint: ${err.hint}`);
34526
34552
  }
34527
34553
  throw err;
34528
34554
  }
34555
+ function callSignalFrom(args) {
34556
+ const ctx = args.at(-1);
34557
+ if (typeof ctx !== "object" || ctx === null)
34558
+ return void 0;
34559
+ const req = ctx.mcpReq;
34560
+ if (typeof req !== "object" || req === null)
34561
+ return void 0;
34562
+ const signal = req.signal;
34563
+ return signal instanceof AbortSignal ? signal : void 0;
34564
+ }
34529
34565
  function surfaceToolHints(server) {
34530
34566
  const register2 = server.registerTool.bind(server);
34531
- server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => {
34532
- let result;
34533
- try {
34534
- result = cb(...args);
34535
- } catch (err) {
34536
- return hintResultOrRethrow(err);
34537
- }
34538
- return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
34539
- });
34567
+ server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
34568
+ // THE CALLER'S CANCELLATION, made ambient for the whole handler
34569
+ // (`cancel/index.ts`). The SDK delivers it and the fleet ignored it:
34570
+ // measured against @modelcontextprotocol/server 2.0.0, a cancelled
34571
+ // call aborts `ctx.mcpReq.signal` with the caller's reason and the
34572
+ // handler runs to completion regardless — so the HTTP request stays
34573
+ // in flight, the child keeps burning metered CPU, and the upstream
34574
+ // keeps being hit for somebody who has gone. claude.ai sent 101
34575
+ // cancellations in the week to 2026-09-20.
34576
+ //
34577
+ // Here because this is the one wrapper every tool already passes
34578
+ // through: threading the signal by hand would mean editing several
34579
+ // hundred handlers and missing exactly the ones nobody edits.
34580
+ withCallSignal(callSignalFrom(args), () => {
34581
+ let result;
34582
+ try {
34583
+ result = cb(...args);
34584
+ } catch (err) {
34585
+ return hintResultOrRethrow(err);
34586
+ }
34587
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
34588
+ })
34589
+ ));
34540
34590
  }
34541
34591
  async function createMcpServer(opts) {
34542
34592
  const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
@@ -34971,8 +35021,15 @@ async function spawnGog(fullArgs, opts) {
34971
35021
  const stdoutChunks = [];
34972
35022
  const stderrChunks = [];
34973
35023
  let settled = false;
35024
+ const cancelled = currentCallSignal();
35025
+ const stopWatchingCancel = killOnCancel(child);
35026
+ const stopWatching = () => {
35027
+ clearTimeout(timer);
35028
+ stopWatchingCancel();
35029
+ };
34974
35030
  const timer = setTimeout(() => {
34975
35031
  settled = true;
35032
+ stopWatchingCancel();
34976
35033
  child.kill();
34977
35034
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
34978
35035
  }, effectiveTimeout);
@@ -34983,9 +35040,13 @@ async function spawnGog(fullArgs, opts) {
34983
35040
  stderrChunks.push(chunk);
34984
35041
  });
34985
35042
  child.on("close", (code) => {
34986
- clearTimeout(timer);
35043
+ stopWatching();
34987
35044
  if (settled) return;
34988
35045
  settled = true;
35046
+ if (cancelled?.aborted) {
35047
+ reject(cancelled.reason instanceof Error ? cancelled.reason : new Error("the caller cancelled this call"));
35048
+ return;
35049
+ }
34989
35050
  const stderr = Buffer.concat(stderrChunks).toString().trim();
34990
35051
  if (code === 0) {
34991
35052
  if (binary) {
@@ -35003,7 +35064,7 @@ async function spawnGog(fullArgs, opts) {
35003
35064
  }
35004
35065
  });
35005
35066
  child.on("error", (err) => {
35006
- clearTimeout(timer);
35067
+ stopWatching();
35007
35068
  if (settled) return;
35008
35069
  settled = true;
35009
35070
  if (err.code === "ENOENT") {
@@ -37521,7 +37582,7 @@ function registerTasksTools(server) {
37521
37582
  }
37522
37583
 
37523
37584
  // src/server.ts
37524
- var VERSION = true ? "4.1.0" : "0.0.0";
37585
+ var VERSION = true ? "4.2.0" : "0.0.0";
37525
37586
  var BASE_TOOL_REGISTRARS = [
37526
37587
  registerApiTools,
37527
37588
  registerAppScriptTools,
package/dist/lib.js CHANGED
@@ -19672,6 +19672,29 @@ function date4(params) {
19672
19672
  return _coercedDate(ZodDate, params);
19673
19673
  }
19674
19674
 
19675
+ // ../../node_modules/@chrischall/mcp-utils/dist/cancel/index.js
19676
+ import { AsyncLocalStorage } from "node:async_hooks";
19677
+ var storage = new AsyncLocalStorage();
19678
+ function currentCallSignal() {
19679
+ return storage.getStore()?.signal;
19680
+ }
19681
+ function killOnCancel(child, signal = "SIGTERM") {
19682
+ const ambient = currentCallSignal();
19683
+ if (!ambient)
19684
+ return () => {
19685
+ };
19686
+ if (ambient.aborted) {
19687
+ child.kill(signal);
19688
+ return () => {
19689
+ };
19690
+ }
19691
+ const onAbort = () => {
19692
+ child.kill(signal);
19693
+ };
19694
+ ambient.addEventListener("abort", onAbort, { once: true });
19695
+ return () => ambient.removeEventListener("abort", onAbort);
19696
+ }
19697
+
19675
19698
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
19676
19699
  var __create = Object.create;
19677
19700
  var __defProp2 = Object.defineProperty;
@@ -33364,8 +33387,15 @@ async function spawnGog(fullArgs, opts) {
33364
33387
  const stdoutChunks = [];
33365
33388
  const stderrChunks = [];
33366
33389
  let settled = false;
33390
+ const cancelled = currentCallSignal();
33391
+ const stopWatchingCancel = killOnCancel(child);
33392
+ const stopWatching = () => {
33393
+ clearTimeout(timer);
33394
+ stopWatchingCancel();
33395
+ };
33367
33396
  const timer = setTimeout(() => {
33368
33397
  settled = true;
33398
+ stopWatchingCancel();
33369
33399
  child.kill();
33370
33400
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
33371
33401
  }, effectiveTimeout);
@@ -33376,9 +33406,13 @@ async function spawnGog(fullArgs, opts) {
33376
33406
  stderrChunks.push(chunk);
33377
33407
  });
33378
33408
  child.on("close", (code) => {
33379
- clearTimeout(timer);
33409
+ stopWatching();
33380
33410
  if (settled) return;
33381
33411
  settled = true;
33412
+ if (cancelled?.aborted) {
33413
+ reject(cancelled.reason instanceof Error ? cancelled.reason : new Error("the caller cancelled this call"));
33414
+ return;
33415
+ }
33382
33416
  const stderr = Buffer.concat(stderrChunks).toString().trim();
33383
33417
  if (code === 0) {
33384
33418
  if (binary) {
@@ -33396,7 +33430,7 @@ async function spawnGog(fullArgs, opts) {
33396
33430
  }
33397
33431
  });
33398
33432
  child.on("error", (err) => {
33399
- clearTimeout(timer);
33433
+ stopWatching();
33400
33434
  if (settled) return;
33401
33435
  settled = true;
33402
33436
  if (err.code === "ENOENT") {
@@ -35917,7 +35951,7 @@ function registerTasksTools(server) {
35917
35951
  }
35918
35952
 
35919
35953
  // src/server.ts
35920
- var VERSION = true ? "4.1.0" : "0.0.0";
35954
+ var VERSION = true ? "4.2.0" : "0.0.0";
35921
35955
  var BASE_TOOL_REGISTRARS = [
35922
35956
  registerApiTools,
35923
35957
  registerAppScriptTools,
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": "4.1.0",
6
+ "version": "4.2.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": "4.1.0",
3
+ "version": "4.2.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>",
@@ -41,12 +41,12 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^1.0.0",
44
+ "@chrischall/mcp-utils": "^2.0.0",
45
45
  "@modelcontextprotocol/server": "^2.0.0",
46
46
  "zod": "^4.6.1"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.5.1",
49
+ "@types/node": "^26.6.1",
50
50
  "@vitest/coverage-v8": "^5.0.1",
51
51
  "esbuild": "^0.28.2",
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": "4.1.0",
10
+ "version": "4.2.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.1.0",
15
+ "version": "4.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
package/src/runner.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ChildProcess } from 'node:child_process';
2
2
  import { delimiter, join } from 'node:path';
3
- import { parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
3
+ import { currentCallSignal, killOnCancel, parseBoolEnv, readEnvVar, redactSecrets as redactSharedSecrets } from '@chrischall/mcp-utils';
4
4
  import { naiveSourceTimeZone } from './timestamps.js';
5
5
 
6
6
  export type Spawner = (
@@ -380,8 +380,26 @@ async function spawnGog(
380
380
  const stderrChunks: Buffer[] = [];
381
381
  let settled = false;
382
382
 
383
+ // THE CALLER GIVING UP KILLS THE CHILD (mcp-utils `cancel`). Until this,
384
+ // a cancelled tool call left a whole `gog` process running to the
385
+ // timeout below — still talking to Google, still charged to the CPU a
386
+ // hosted child is metered on, for somebody who has gone. Measured on the
387
+ // mcp-host fleet: claude.ai sent 101 cancellations in the week to
388
+ // 2026-09-20, and every one of them was ignored here.
389
+ //
390
+ // The signal is ambient rather than passed: `surfaceToolHints` puts it
391
+ // in scope for the whole handler, so this works for every tool in every
392
+ // package without one of them threading it through.
393
+ const cancelled = currentCallSignal();
394
+ const stopWatchingCancel = killOnCancel(child);
395
+ const stopWatching = (): void => {
396
+ clearTimeout(timer);
397
+ stopWatchingCancel();
398
+ };
399
+
383
400
  const timer = setTimeout(() => {
384
401
  settled = true;
402
+ stopWatchingCancel();
385
403
  child.kill();
386
404
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
387
405
  }, effectiveTimeout);
@@ -390,9 +408,17 @@ async function spawnGog(
390
408
  child.stderr!.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); });
391
409
 
392
410
  child.on('close', (code: number | null) => {
393
- clearTimeout(timer);
411
+ stopWatching();
394
412
  if (settled) return;
395
413
  settled = true;
414
+ // A child WE killed because the caller left exits with no code and no
415
+ // stderr, which the branch below would report as `gog exited with code
416
+ // null` — a sentence about gogcli for something gogcli did not do. The
417
+ // caller's own reason is the honest error.
418
+ if (cancelled?.aborted) {
419
+ reject(cancelled.reason instanceof Error ? cancelled.reason : new Error('the caller cancelled this call'));
420
+ return;
421
+ }
396
422
  const stderr = Buffer.concat(stderrChunks).toString().trim();
397
423
  if (code === 0) {
398
424
  // Binary mode: return the raw stdout bytes base64-encoded, never a utf8
@@ -413,7 +439,7 @@ async function spawnGog(
413
439
  });
414
440
 
415
441
  child.on('error', (err: Error) => {
416
- clearTimeout(timer);
442
+ stopWatching();
417
443
  if (settled) return;
418
444
  settled = true;
419
445
  if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { spawn as mockedSpawn } from 'node:child_process';
4
+ import { withCallSignal } from '@chrischall/mcp-utils';
4
5
  import { run, runBinary } from '../src/runner.js';
5
6
  import type { Spawner } from '../src/runner.js';
6
7
 
@@ -949,3 +950,74 @@ describe('run executor', () => {
949
950
  );
950
951
  });
951
952
  });
953
+
954
+ /**
955
+ * The caller giving up kills the `gog` child (mcp-utils `cancel`).
956
+ *
957
+ * Until this, a cancelled tool call left a whole process running to the
958
+ * runner's own timeout — still talking to Google, still charged to the CPU a
959
+ * hosted child is metered on, for somebody who has gone. Measured on the
960
+ * mcp-host fleet: claude.ai sent 101 cancellations in the week to
961
+ * 2026-09-20, and every one of them was ignored here.
962
+ */
963
+ describe('cancellation', () => {
964
+ /** A child that never finishes on its own — only a kill ends it. */
965
+ function hangingProc(): ReturnType<Spawner> & { killed: unknown[] } {
966
+ const proc = new EventEmitter() as ReturnType<Spawner> & { killed: unknown[] };
967
+ const io = proc as unknown as { stdout: EventEmitter; stderr: EventEmitter };
968
+ io.stdout = new EventEmitter();
969
+ io.stderr = new EventEmitter();
970
+ proc.killed = [];
971
+ proc.kill = vi.fn((sig?: unknown) => {
972
+ proc.killed.push(sig);
973
+ // A killed child closes with no code and no stderr, which is exactly
974
+ // the shape the error path has to tell apart from a gog failure.
975
+ setTimeout(() => proc.emit('close', null), 0);
976
+ return true;
977
+ }) as unknown as ReturnType<Spawner>['kill'];
978
+ return proc;
979
+ }
980
+
981
+ it('kills the child and rejects with the caller’s own reason', async () => {
982
+ const proc = hangingProc();
983
+ const controller = new AbortController();
984
+ const reason = new Error('caller went away');
985
+
986
+ const call = withCallSignal(controller.signal, () =>
987
+ run(['sheets', 'get', 'id1', 'A1'], { spawner: (() => proc) as unknown as Spawner }),
988
+ );
989
+ controller.abort(reason);
990
+
991
+ // Not `gog exited with code null`, which is what the ordinary close path
992
+ // would have said — a sentence about gogcli for something gogcli did not
993
+ // do.
994
+ await expect(call).rejects.toThrow(reason);
995
+ expect(proc.killed, 'the gog child was left running').toEqual(['SIGTERM']);
996
+ });
997
+
998
+ it('names the cancellation when the reason is not an Error', async () => {
999
+ // `AbortController.abort()` takes ANY value — a string, a DOMException,
1000
+ // undefined — so the reason is not guaranteed to be throwable. Rejecting
1001
+ // with a raw string would surface as an error with no message at the
1002
+ // tool boundary, which is the shape this whole change exists to avoid.
1003
+ const proc = hangingProc();
1004
+ const controller = new AbortController();
1005
+
1006
+ const call = withCallSignal(controller.signal, () =>
1007
+ run(['sheets', 'get', 'id1', 'A1'], { spawner: (() => proc) as unknown as Spawner }),
1008
+ );
1009
+ controller.abort('the caller went away');
1010
+
1011
+ await expect(call).rejects.toThrow(/cancelled/i);
1012
+ expect(proc.killed).toEqual(['SIGTERM']);
1013
+ });
1014
+
1015
+ it('leaves an ordinary call alone, and stops listening when it finishes', async () => {
1016
+ const controller = new AbortController();
1017
+ const spawner = makeSpawner(0, '{"ok":true}');
1018
+ await withCallSignal(controller.signal, () => run(['sheets', 'get', 'id1', 'A1'], { spawner }));
1019
+ // Aborting AFTER the call settled must reach nothing: a listener that
1020
+ // outlives its call is a leak per invocation.
1021
+ expect(() => controller.abort(new Error('too late'))).not.toThrow();
1022
+ });
1023
+ });