gogcli-mcp 4.0.1 → 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.0.1"
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.0.1",
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.0.1",
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/README.md CHANGED
@@ -10,7 +10,7 @@ For extended Sheets or Docs support, see [gogcli-mcp-sheets](https://www.npmjs.c
10
10
  ## Requirements
11
11
 
12
12
  - [gogcli](https://github.com/openclaw/gogcli) installed and authenticated
13
- - Node.js 18+
13
+ - Node.js 22+
14
14
 
15
15
  ```bash
16
16
  brew install gogcli
package/SKILL.md CHANGED
@@ -12,7 +12,7 @@ MCP server wrapping [gogcli](https://github.com/openclaw/gogcli) — provides Cl
12
12
  ## Requirements
13
13
 
14
14
  - [gogcli](https://github.com/openclaw/gogcli) installed and authenticated
15
- - Node.js 18 or later
15
+ - Node.js 22 or later
16
16
 
17
17
  ## Setup
18
18
 
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;
@@ -20845,6 +20871,7 @@ function missingClientCapabilities(required2, declared) {
20845
20871
  return Object.keys(missing).length > 0 ? missing : void 0;
20846
20872
  }
20847
20873
  var FIRST_MODERN_PROTOCOL_VERSION = "2026-07-28";
20874
+ var SUPPORTED_MODERN_PROTOCOL_VERSIONS = [FIRST_MODERN_PROTOCOL_VERSION];
20848
20875
  function isModernProtocolVersion(version2) {
20849
20876
  return version2 >= FIRST_MODERN_PROTOCOL_VERSION;
20850
20877
  }
@@ -23403,6 +23430,25 @@ function isSpecNotificationMethod(method) {
23403
23430
  return ALL_CODECS.some((codec2) => codec2.hasNotificationMethod(method));
23404
23431
  }
23405
23432
  var ALL_CODECS = [rev2025Codec, rev2026Codec];
23433
+ function isPlainObject$3(value) {
23434
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23435
+ }
23436
+ function requestMetaOf(params) {
23437
+ if (!isPlainObject$3(params)) return void 0;
23438
+ const meta3 = params["_meta"];
23439
+ return isPlainObject$3(meta3) ? meta3 : void 0;
23440
+ }
23441
+ function hasEnvelopeClaim(params) {
23442
+ const meta3 = requestMetaOf(params);
23443
+ return meta3 !== void 0 && PROTOCOL_VERSION_META_KEY in meta3;
23444
+ }
23445
+ function envelopeClaimVersion(params) {
23446
+ const value = requestMetaOf(params)?.[PROTOCOL_VERSION_META_KEY];
23447
+ return typeof value === "string" ? value : void 0;
23448
+ }
23449
+ function validateEnvelopeMeta(meta3) {
23450
+ return codecForVersion(MODERN_WIRE_REVISION).validateEnvelopeMeta(meta3);
23451
+ }
23406
23452
  var schemas_exports3 = /* @__PURE__ */ __exportAll({
23407
23453
  AnnotationsSchema: () => AnnotationsSchema,
23408
23454
  AudioContentSchema: () => AudioContentSchema,
@@ -23753,6 +23799,45 @@ var LADDER_ERROR_HTTP_STATUS = {
23753
23799
  [ProtocolErrorCode.MissingRequiredClientCapability]: 400,
23754
23800
  [HEADER_MISMATCH_ERROR_CODE]: 400
23755
23801
  };
23802
+ function rejection(rung, cell, httpStatus, error62, settled) {
23803
+ return {
23804
+ kind: "reject",
23805
+ rung,
23806
+ cell,
23807
+ httpStatus,
23808
+ code: error62.code,
23809
+ message: error62.message,
23810
+ ...error62.data !== void 0 && { data: error62.data },
23811
+ settled
23812
+ };
23813
+ }
23814
+ function carriesValidModernEnvelopeClaim(params) {
23815
+ if (!hasEnvelopeClaim(params)) return false;
23816
+ const claimedVersion = envelopeClaimVersion(params);
23817
+ if (claimedVersion === void 0 || !isModernProtocolVersion(claimedVersion)) return false;
23818
+ const meta3 = requestMetaOf(params);
23819
+ return meta3 !== void 0 && validateEnvelopeMeta(meta3).length === 0;
23820
+ }
23821
+ function modernOnlyStrictRejection(route, supportedVersions) {
23822
+ switch (route.reason) {
23823
+ case "http-method":
23824
+ return rejection("http-method", "modern-only-method-not-allowed", 405, new ProtocolError(-32e3, "Method not allowed."), true);
23825
+ case "batch":
23826
+ return rejection("jsonrpc-shape", "modern-only-batch-not-supported", 400, new ProtocolError(ProtocolErrorCode.InvalidRequest, "Bad Request: JSON-RPC batches are not supported by this endpoint"), true);
23827
+ case "response":
23828
+ return rejection("jsonrpc-shape", "modern-only-response-post", 400, new ProtocolError(ProtocolErrorCode.InvalidRequest, "Bad Request: JSON-RPC responses cannot be posted to this endpoint"), true);
23829
+ case "notification":
23830
+ return;
23831
+ case "initialize":
23832
+ case "no-claim": {
23833
+ const requested = route.requestedVersion;
23834
+ return rejection("era-classification", "modern-only-missing-envelope", 400, requested === void 0 ? new ProtocolError(ProtocolErrorCode.UnsupportedProtocolVersion, "Unsupported protocol version: the request did not name a protocol version", { supported: [...supportedVersions] }) : new UnsupportedProtocolVersionError({
23835
+ supported: [...supportedVersions],
23836
+ requested
23837
+ }), true);
23838
+ }
23839
+ }
23840
+ }
23756
23841
  function parseSchema(schema, data) {
23757
23842
  return safeParse2(schema, data);
23758
23843
  }
@@ -24345,6 +24430,9 @@ function withRequestStateValue(ctx, value) {
24345
24430
  };
24346
24431
  }
24347
24432
  var writeNegotiatedProtocolVersion;
24433
+ function setNegotiatedProtocolVersion(instance, version2) {
24434
+ writeNegotiatedProtocolVersion(instance, version2);
24435
+ }
24348
24436
  var Protocol = class {
24349
24437
  _transport;
24350
24438
  _requestMessageId = 0;
@@ -32288,6 +32376,190 @@ function getCompleter(schema) {
32288
32376
  return schema[COMPLETABLE_SYMBOL]?.complete;
32289
32377
  }
32290
32378
  var MAX_TIMER_DELAY_MS = 2 ** 31 - 1;
32379
+ function listenFilterAccepts(filter, event) {
32380
+ switch (event.kind) {
32381
+ case "tools_list_changed":
32382
+ return filter.toolsListChanged === true;
32383
+ case "prompts_list_changed":
32384
+ return filter.promptsListChanged === true;
32385
+ case "resources_list_changed":
32386
+ return filter.resourcesListChanged === true;
32387
+ case "resource_updated":
32388
+ return filter.resourceSubscriptions !== void 0 && filter.resourceSubscriptions.includes(event.uri);
32389
+ }
32390
+ }
32391
+ function honoredSubset(requested, capabilities) {
32392
+ const honored = {};
32393
+ const allow = (bit) => capabilities === void 0 || bit === true;
32394
+ if (requested.toolsListChanged === true && allow(capabilities?.tools?.listChanged)) honored.toolsListChanged = true;
32395
+ if (requested.promptsListChanged === true && allow(capabilities?.prompts?.listChanged)) honored.promptsListChanged = true;
32396
+ if (requested.resourcesListChanged === true && allow(capabilities?.resources?.listChanged)) honored.resourcesListChanged = true;
32397
+ if (requested.resourceSubscriptions !== void 0 && requested.resourceSubscriptions.length > 0 && allow(capabilities?.resources?.subscribe)) honored.resourceSubscriptions = [...requested.resourceSubscriptions];
32398
+ return honored;
32399
+ }
32400
+ var DEFAULT_MAX_SUBSCRIPTIONS = 1024;
32401
+ function stampSubscriptionId(notification, subscriptionId) {
32402
+ return {
32403
+ method: notification.method,
32404
+ params: {
32405
+ ...notification.params,
32406
+ _meta: {
32407
+ ...notification.params?._meta,
32408
+ [SUBSCRIPTION_ID_META_KEY]: subscriptionId
32409
+ }
32410
+ }
32411
+ };
32412
+ }
32413
+ function parseListenFilter(message) {
32414
+ const outcome = codecForVersion(MODERN_WIRE_REVISION).validateRequest("subscriptions/listen", message);
32415
+ return outcome.ok ? outcome.value.params?.notifications : void 0;
32416
+ }
32417
+ var CHANGE_NOTIFICATION_METHODS = /* @__PURE__ */ new Set([
32418
+ "notifications/tools/list_changed",
32419
+ "notifications/prompts/list_changed",
32420
+ "notifications/resources/list_changed",
32421
+ "notifications/resources/updated"
32422
+ ]);
32423
+ var StdioListenRouter = class {
32424
+ /** Active subscriptions, keyed by the listen request's JSON-RPC id verbatim. */
32425
+ _subs = /* @__PURE__ */ new Map();
32426
+ /**
32427
+ * The serving instance's declared capabilities. Filled in by the entry
32428
+ * once the modern instance is constructed (the router is created before
32429
+ * the instance exists), so the acknowledged filter is narrowed against
32430
+ * what the server can actually deliver.
32431
+ */
32432
+ _serverCapabilities;
32433
+ /**
32434
+ * The serving instance's identity, stamped onto the graceful-close
32435
+ * results' `_meta` (the spec's `SubscriptionsListenResultMeta` extends
32436
+ * `ResultMetaObject`). Handed over together with the capabilities.
32437
+ */
32438
+ _serverInfo;
32439
+ constructor(_maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS, serverCapabilities, serverInfo) {
32440
+ this._maxSubscriptions = _maxSubscriptions;
32441
+ this._serverCapabilities = serverCapabilities;
32442
+ this._serverInfo = serverInfo;
32443
+ }
32444
+ /**
32445
+ * Record the serving instance's declared capabilities and identity once
32446
+ * it has been constructed. Called by `serveStdio`'s connect path;
32447
+ * subsequent `serve()` calls narrow the honored filter against the
32448
+ * capabilities, and `teardownAll()` stamps the identity.
32449
+ */
32450
+ setServerCapabilities(capabilities, serverInfo) {
32451
+ this._serverCapabilities = capabilities;
32452
+ if (serverInfo !== void 0) this._serverInfo = serverInfo;
32453
+ }
32454
+ /** Whether `id` is an active listen subscription on this connection. */
32455
+ has(id) {
32456
+ return this._subs.has(id);
32457
+ }
32458
+ /**
32459
+ * Serve one inbound `subscriptions/listen` request: registers the
32460
+ * subscription and returns the stamped acknowledged notification (or, on
32461
+ * capacity / params rejection, the in-band JSON-RPC error response).
32462
+ *
32463
+ * @throws when called before {@linkcode setServerCapabilities} (or the
32464
+ * constructor) has supplied the serving instance's capabilities. Honoring a
32465
+ * filter without knowing the server's advertised capabilities would fail
32466
+ * open (deliver unadvertised types); the entry guarantees capabilities are
32467
+ * set before any listen request is routed here.
32468
+ */
32469
+ serve(message) {
32470
+ if (this._serverCapabilities === void 0) throw new Error("StdioListenRouter.serve() called before setServerCapabilities(); refusing to honor a filter without capabilities");
32471
+ if (this._subs.size >= this._maxSubscriptions) return {
32472
+ jsonrpc: "2.0",
32473
+ id: message.id,
32474
+ error: {
32475
+ code: -32603,
32476
+ message: "Subscription limit reached"
32477
+ }
32478
+ };
32479
+ const filter = parseListenFilter(message);
32480
+ if (filter === void 0) return {
32481
+ jsonrpc: "2.0",
32482
+ id: message.id,
32483
+ error: {
32484
+ code: -32602,
32485
+ message: "Invalid params: 'notifications' is required and must be a valid SubscriptionFilter"
32486
+ }
32487
+ };
32488
+ const honored = honoredSubset(filter, this._serverCapabilities);
32489
+ this._subs.set(message.id, honored);
32490
+ return stampSubscriptionId({
32491
+ method: "notifications/subscriptions/acknowledged",
32492
+ params: { notifications: honored }
32493
+ }, message.id);
32494
+ }
32495
+ /**
32496
+ * Tear down one subscription (inbound `notifications/cancelled`). Returns
32497
+ * `true` when a subscription was removed. After this call NOTHING further
32498
+ * is delivered for that subscription id (the post-cancel hardening).
32499
+ */
32500
+ cancel(id) {
32501
+ return this._subs.delete(id);
32502
+ }
32503
+ /**
32504
+ * Route an outbound notification through the active subscriptions.
32505
+ *
32506
+ * - For a subscription-gated change notification, returns one stamped copy
32507
+ * per subscription that opted in to it (an empty array means it is
32508
+ * dropped — the modern era never delivers an un-requested change type).
32509
+ * - For any other outbound message, returns `'passthrough'` (the entry
32510
+ * forwards it as-is).
32511
+ */
32512
+ routeOutbound(message) {
32513
+ if (!CHANGE_NOTIFICATION_METHODS.has(message.method)) return "passthrough";
32514
+ const uriParam = message.params?.["uri"];
32515
+ const uri = typeof uriParam === "string" ? uriParam : void 0;
32516
+ const event = notificationToServerEvent(message.method, uri);
32517
+ const out = [];
32518
+ for (const [subscriptionId, filter] of this._subs) if (listenFilterAccepts(filter, event)) out.push(stampSubscriptionId({
32519
+ method: message.method,
32520
+ params: message.params ?? {}
32521
+ }, subscriptionId));
32522
+ return out;
32523
+ }
32524
+ /**
32525
+ * Server-side graceful teardown of every active subscription: returns the
32526
+ * empty `subscriptions/listen` JSON-RPC result for each subscription id —
32527
+ * the spec's graceful-close signal, `_meta` carrying the subscription id
32528
+ * and the serving instance's identity — for the entry to emit before
32529
+ * closing the wire. Clears the set so nothing further is delivered.
32530
+ */
32531
+ teardownAll() {
32532
+ const out = [];
32533
+ for (const id of this._subs.keys()) out.push({
32534
+ jsonrpc: "2.0",
32535
+ id,
32536
+ result: {
32537
+ resultType: "complete",
32538
+ _meta: {
32539
+ [SUBSCRIPTION_ID_META_KEY]: id,
32540
+ ...this._serverInfo !== void 0 && { [SERVER_INFO_META_KEY]: this._serverInfo }
32541
+ }
32542
+ }
32543
+ });
32544
+ this._subs.clear();
32545
+ return out;
32546
+ }
32547
+ };
32548
+ function notificationToServerEvent(method, uri) {
32549
+ switch (method) {
32550
+ case "notifications/tools/list_changed":
32551
+ return { kind: "tools_list_changed" };
32552
+ case "notifications/prompts/list_changed":
32553
+ return { kind: "prompts_list_changed" };
32554
+ case "notifications/resources/list_changed":
32555
+ return { kind: "resources_list_changed" };
32556
+ default:
32557
+ return {
32558
+ kind: "resource_updated",
32559
+ uri: uri ?? ""
32560
+ };
32561
+ }
32562
+ }
32291
32563
  var DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8;
32292
32564
  var DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 6e5;
32293
32565
  function resolveLegacyShimOptions(options) {
@@ -32425,6 +32697,12 @@ var INPUT_REQUIRED_CAPABLE_METHODS = /* @__PURE__ */ new Set([
32425
32697
  var writeClientIdentity;
32426
32698
  var installDiscoverHandler;
32427
32699
  var readServerIdentity;
32700
+ function installModernOnlyHandlers(server, servedModernVersions) {
32701
+ installDiscoverHandler(server, servedModernVersions);
32702
+ }
32703
+ function serverIdentityOf(server) {
32704
+ return readServerIdentity(server);
32705
+ }
32428
32706
  var Server = class extends Protocol {
32429
32707
  _clientCapabilities;
32430
32708
  _clientVersion;
@@ -33699,6 +33977,400 @@ var StdioServerTransport = class {
33699
33977
  });
33700
33978
  }
33701
33979
  };
33980
+ var DISCARD_ANSWER_TIMEOUT_MS = 3e3;
33981
+ var StdioConnectionChannel = class {
33982
+ onclose;
33983
+ onerror;
33984
+ onmessage;
33985
+ _closed = false;
33986
+ /** Request ids the entry delivered to the instance that the instance has not yet answered. */
33987
+ _pendingRequests = /* @__PURE__ */ new Set();
33988
+ _drainWaiters = [];
33989
+ constructor(_wire, _onInstanceClose, _outboundIntercept) {
33990
+ this._wire = _wire;
33991
+ this._onInstanceClose = _onInstanceClose;
33992
+ this._outboundIntercept = _outboundIntercept;
33993
+ }
33994
+ async start() {
33995
+ }
33996
+ async send(message, options) {
33997
+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
33998
+ const { id } = message;
33999
+ if (id !== void 0) this._settle(id);
34000
+ }
34001
+ if (this._closed) return;
34002
+ if (this._outboundIntercept?.(message) === "handled") return;
34003
+ return this._wire.send(message, options);
34004
+ }
34005
+ setProtocolVersion = (version2) => {
34006
+ this._wire.setProtocolVersion?.(version2);
34007
+ };
34008
+ /** Forwards one inbound message to the connected instance. */
34009
+ deliver(message, extra) {
34010
+ if (this._closed) return;
34011
+ if (isJSONRPCRequest(message)) this._pendingRequests.add(message.id);
34012
+ else if (isJSONRPCNotification(message) && message.method === "notifications/cancelled") {
34013
+ const cancelledId = message.params?.requestId;
34014
+ if (cancelledId !== void 0) this._settle(cancelledId);
34015
+ }
34016
+ this.onmessage?.(message, extra);
34017
+ }
34018
+ /**
34019
+ * Resolves once every request delivered to the instance has been answered
34020
+ * through {@linkcode send}, settled by a delivered cancellation, or the
34021
+ * channel has been closed and nothing further can be answered. The wait is
34022
+ * bounded by `timeoutMs` as a backstop so no edge can hold the caller
34023
+ * indefinitely; resolves `false` only when the bound elapsed with requests
34024
+ * still unanswered. Used by the probe-discard path so a probe request the
34025
+ * entry accepted is never silently dropped.
34026
+ */
34027
+ async whenRequestsAnswered(timeoutMs) {
34028
+ if (this._closed || this._pendingRequests.size === 0) return true;
34029
+ return await new Promise((resolve) => {
34030
+ const waiter = () => {
34031
+ clearTimeout(timer);
34032
+ resolve(true);
34033
+ };
34034
+ const timer = setTimeout(() => {
34035
+ this._drainWaiters = this._drainWaiters.filter((pending) => pending !== waiter);
34036
+ resolve(false);
34037
+ }, timeoutMs);
34038
+ this._drainWaiters.push(waiter);
34039
+ });
34040
+ }
34041
+ async close() {
34042
+ if (this._closed) return;
34043
+ this._closed = true;
34044
+ this._pendingRequests.clear();
34045
+ this._releaseDrainWaiters();
34046
+ try {
34047
+ this._onInstanceClose();
34048
+ } finally {
34049
+ this.onclose?.();
34050
+ }
34051
+ }
34052
+ _settle(id) {
34053
+ this._pendingRequests.delete(id);
34054
+ if (this._pendingRequests.size === 0) this._releaseDrainWaiters();
34055
+ }
34056
+ _releaseDrainWaiters() {
34057
+ const waiters = this._drainWaiters;
34058
+ this._drainWaiters = [];
34059
+ for (const waiter of waiters) waiter();
34060
+ }
34061
+ };
34062
+ function classifyOpeningMessage(message) {
34063
+ const params = message.params;
34064
+ if (message.method === "initialize" && !carriesValidModernEnvelopeClaim(params)) {
34065
+ const requestedVersion = params !== null && typeof params === "object" && typeof params.protocolVersion === "string" ? params.protocolVersion : void 0;
34066
+ return {
34067
+ kind: "legacy",
34068
+ reason: "initialize",
34069
+ ...requestedVersion !== void 0 && { requestedVersion }
34070
+ };
34071
+ }
34072
+ if (!hasEnvelopeClaim(params)) return {
34073
+ kind: "legacy",
34074
+ reason: "no-claim"
34075
+ };
34076
+ const meta3 = requestMetaOf(params);
34077
+ const firstIssue = (meta3 === void 0 ? [] : validateEnvelopeMeta(meta3))[0];
34078
+ if (firstIssue !== void 0) return {
34079
+ kind: "invalid-envelope",
34080
+ issue: firstIssue
34081
+ };
34082
+ const claimedVersion = envelopeClaimVersion(params);
34083
+ if (claimedVersion === void 0 || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) return {
34084
+ kind: "unsupported-revision",
34085
+ requested: claimedVersion ?? "unknown"
34086
+ };
34087
+ return {
34088
+ kind: "modern",
34089
+ revision: claimedVersion,
34090
+ classification: {
34091
+ era: "modern",
34092
+ revision: claimedVersion
34093
+ }
34094
+ };
34095
+ }
34096
+ function serveStdio(factory, options = {}) {
34097
+ const legacyMode = options.legacy ?? "serve";
34098
+ const wire = options.transport ?? new StdioServerTransport();
34099
+ let state = { phase: "opening" };
34100
+ let discarding;
34101
+ let closing = false;
34102
+ const isTornDown = () => closing || state.phase === "closed";
34103
+ const reportError = (error62) => {
34104
+ try {
34105
+ options.onerror?.(error62);
34106
+ } catch {
34107
+ }
34108
+ };
34109
+ const writeErrorResponse = (id, code, message, data) => wire.send({
34110
+ jsonrpc: "2.0",
34111
+ id,
34112
+ error: {
34113
+ code,
34114
+ message,
34115
+ ...data !== void 0 && { data }
34116
+ }
34117
+ }).catch((error62) => reportError(toError(error62)));
34118
+ const listenRouter = new StdioListenRouter(options.maxSubscriptions ?? DEFAULT_MAX_SUBSCRIPTIONS);
34119
+ const modernOutboundIntercept = (message) => {
34120
+ if (!isJSONRPCNotification(message)) return void 0;
34121
+ const routed = listenRouter.routeOutbound(message);
34122
+ if (routed === "passthrough") return void 0;
34123
+ for (const stamped of routed) wire.send({
34124
+ jsonrpc: "2.0",
34125
+ ...stamped
34126
+ }).catch((error62) => reportError(toError(error62)));
34127
+ return "handled";
34128
+ };
34129
+ const tryServeListen = async (message) => {
34130
+ if (isJSONRPCRequest(message) && message.method === "subscriptions/listen") {
34131
+ const meta3 = requestMetaOf(message.params);
34132
+ const issue2 = hasEnvelopeClaim(message.params) ? (meta3 === void 0 ? [] : validateEnvelopeMeta(meta3))[0] : {
34133
+ key: "_meta",
34134
+ problem: "the per-request envelope is required on protocol revision 2026-07-28"
34135
+ };
34136
+ const claimedVersion = envelopeClaimVersion(message.params);
34137
+ let reply;
34138
+ if (issue2 !== void 0) reply = {
34139
+ jsonrpc: "2.0",
34140
+ id: message.id,
34141
+ error: {
34142
+ code: -32602,
34143
+ message: `Invalid _meta envelope: ${issue2.key}: ${issue2.problem}`
34144
+ }
34145
+ };
34146
+ else if (claimedVersion === void 0 || !SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(claimedVersion)) {
34147
+ const error62 = new UnsupportedProtocolVersionError({
34148
+ supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS],
34149
+ requested: claimedVersion ?? "unknown"
34150
+ });
34151
+ reply = {
34152
+ jsonrpc: "2.0",
34153
+ id: message.id,
34154
+ error: {
34155
+ code: error62.code,
34156
+ message: error62.message,
34157
+ data: error62.data
34158
+ }
34159
+ };
34160
+ } else reply = listenRouter.serve(message);
34161
+ await wire.send("error" in reply ? reply : {
34162
+ jsonrpc: "2.0",
34163
+ method: reply.method,
34164
+ params: reply.params
34165
+ }).catch((error62) => reportError(toError(error62)));
34166
+ return true;
34167
+ }
34168
+ if (isJSONRPCNotification(message) && message.method === "notifications/cancelled") {
34169
+ const cancelledId = message.params?.requestId;
34170
+ if (cancelledId !== void 0 && listenRouter.cancel(cancelledId)) return true;
34171
+ }
34172
+ return false;
34173
+ };
34174
+ const answerLegacyRejection = (request, reason, requestedVersion) => {
34175
+ const rejection2 = modernOnlyStrictRejection({
34176
+ kind: "legacy",
34177
+ reason,
34178
+ ...requestedVersion !== void 0 && { requestedVersion }
34179
+ }, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
34180
+ if (rejection2 === void 0) return Promise.resolve();
34181
+ reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection2.cell}): ${rejection2.message}`));
34182
+ return writeErrorResponse(request.id, rejection2.code, rejection2.message, rejection2.data);
34183
+ };
34184
+ const onInstanceClosed = (channel) => {
34185
+ if (closing || channel === discarding) return;
34186
+ closeAll();
34187
+ };
34188
+ const connectInstance = async (era, revision) => {
34189
+ const product = await factory({ era });
34190
+ const server = product instanceof McpServer ? product.server : product;
34191
+ if (era === "modern") {
34192
+ setNegotiatedProtocolVersion(server, revision);
34193
+ installModernOnlyHandlers(server, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
34194
+ listenRouter.setServerCapabilities(server.getCapabilities(), serverIdentityOf(server));
34195
+ }
34196
+ const channel = new StdioConnectionChannel(wire, () => onInstanceClosed(channel), era === "modern" ? modernOutboundIntercept : void 0);
34197
+ await product.connect(channel);
34198
+ return {
34199
+ product,
34200
+ channel
34201
+ };
34202
+ };
34203
+ const disposeLateInstance = (instance) => instance.product.close().catch((error62) => reportError(toError(error62)));
34204
+ const discardProbeInstance = async (instance) => {
34205
+ discarding = instance.channel;
34206
+ try {
34207
+ if (!await instance.channel.whenRequestsAnswered(DISCARD_ANSWER_TIMEOUT_MS)) reportError(/* @__PURE__ */ new Error(`Discarded the probe instance with requests still unanswered after ${DISCARD_ANSWER_TIMEOUT_MS}ms; continuing with the fallback`));
34208
+ await instance.product.close();
34209
+ } catch (error62) {
34210
+ reportError(toError(error62));
34211
+ } finally {
34212
+ discarding = void 0;
34213
+ }
34214
+ };
34215
+ const processMessage = async (message) => {
34216
+ if (state.phase === "closed") return;
34217
+ if (state.phase === "pinned") {
34218
+ if (state.era === "modern" && isJSONRPCRequest(message) && message.method === "initialize" && !carriesValidModernEnvelopeClaim(message.params)) {
34219
+ await answerLegacyRejection(message, "initialize", message.params !== null && typeof message.params === "object" && typeof message.params.protocolVersion === "string" ? message.params.protocolVersion : void 0);
34220
+ return;
34221
+ }
34222
+ if (state.era === "modern" && await tryServeListen(message)) return;
34223
+ state.instance.channel.deliver(message);
34224
+ return;
34225
+ }
34226
+ if (!isJSONRPCRequest(message) && !isJSONRPCNotification(message)) {
34227
+ reportError(/* @__PURE__ */ new Error("Discarded a JSON-RPC response received before the connection negotiated an era"));
34228
+ return;
34229
+ }
34230
+ const opening = classifyOpeningMessage(message);
34231
+ switch (opening.kind) {
34232
+ case "invalid-envelope": {
34233
+ const detail = `Invalid _meta envelope for protocol revision 2026-07-28: ${opening.issue.key}: ${opening.issue.problem}`;
34234
+ if (isJSONRPCRequest(message)) await writeErrorResponse(message.id, ProtocolErrorCode.InvalidParams, detail, { envelope: opening.issue });
34235
+ else reportError(/* @__PURE__ */ new Error(`Discarded a notification with a malformed envelope: ${detail}`));
34236
+ return;
34237
+ }
34238
+ case "unsupported-revision":
34239
+ if (isJSONRPCRequest(message)) {
34240
+ const error62 = new UnsupportedProtocolVersionError({
34241
+ supported: [...SUPPORTED_MODERN_PROTOCOL_VERSIONS],
34242
+ requested: opening.requested
34243
+ });
34244
+ reportError(error62);
34245
+ await writeErrorResponse(message.id, error62.code, error62.message, error62.data);
34246
+ } else reportError(/* @__PURE__ */ new Error(`Discarded a notification claiming unsupported protocol revision ${opening.requested}`));
34247
+ return;
34248
+ case "modern":
34249
+ if (isJSONRPCRequest(message) && message.method === "server/discover") {
34250
+ if (state.phase === "probe") {
34251
+ state.instance.channel.deliver(message, { classification: opening.classification });
34252
+ return;
34253
+ }
34254
+ const instance = await connectInstance("modern", opening.revision);
34255
+ if (isTornDown()) {
34256
+ await disposeLateInstance(instance);
34257
+ return;
34258
+ }
34259
+ state = {
34260
+ phase: "probe",
34261
+ instance
34262
+ };
34263
+ instance.channel.deliver(message, { classification: opening.classification });
34264
+ return;
34265
+ }
34266
+ if (state.phase === "probe") {
34267
+ if (isJSONRPCNotification(message)) {
34268
+ state.instance.channel.deliver(message, { classification: opening.classification });
34269
+ return;
34270
+ }
34271
+ state = {
34272
+ phase: "pinned",
34273
+ era: "modern",
34274
+ instance: state.instance
34275
+ };
34276
+ } else {
34277
+ const instance = await connectInstance("modern", opening.revision);
34278
+ if (isTornDown()) {
34279
+ await disposeLateInstance(instance);
34280
+ return;
34281
+ }
34282
+ state = {
34283
+ phase: "pinned",
34284
+ era: "modern",
34285
+ instance
34286
+ };
34287
+ }
34288
+ if (await tryServeListen(message)) return;
34289
+ state.instance.channel.deliver(message, { classification: opening.classification });
34290
+ return;
34291
+ case "legacy": {
34292
+ if (legacyMode === "reject") {
34293
+ if (isJSONRPCRequest(message)) await answerLegacyRejection(message, opening.reason, opening.requestedVersion);
34294
+ return;
34295
+ }
34296
+ if (state.phase === "probe") {
34297
+ await discardProbeInstance(state.instance);
34298
+ if (isTornDown()) return;
34299
+ state = { phase: "opening" };
34300
+ }
34301
+ const instance = await connectInstance("legacy");
34302
+ if (isTornDown()) {
34303
+ await disposeLateInstance(instance);
34304
+ return;
34305
+ }
34306
+ state = {
34307
+ phase: "pinned",
34308
+ era: "legacy",
34309
+ instance
34310
+ };
34311
+ state.instance.channel.deliver(message);
34312
+ return;
34313
+ }
34314
+ }
34315
+ };
34316
+ const queue = [];
34317
+ let pumping = false;
34318
+ const pump = async () => {
34319
+ if (pumping) return;
34320
+ pumping = true;
34321
+ try {
34322
+ while (queue.length > 0) {
34323
+ const message = queue.shift();
34324
+ try {
34325
+ await processMessage(message);
34326
+ } catch (error62) {
34327
+ if (isJSONRPCRequest(message)) await writeErrorResponse(message.id, ProtocolErrorCode.InternalError, "Internal server error");
34328
+ reportError(toError(error62));
34329
+ }
34330
+ }
34331
+ } finally {
34332
+ pumping = false;
34333
+ }
34334
+ };
34335
+ const closeAll = async () => {
34336
+ if (closing || state.phase === "closed") return;
34337
+ closing = true;
34338
+ const current = state;
34339
+ state = { phase: "closed" };
34340
+ for (const result of listenRouter.teardownAll()) await wire.send(result).catch((error62) => reportError(toError(error62)));
34341
+ if (current.phase === "probe" || current.phase === "pinned") await current.instance.product.close().catch((error62) => reportError(toError(error62)));
34342
+ await wire.close().catch((error62) => reportError(toError(error62)));
34343
+ };
34344
+ wire.onmessage = (message) => {
34345
+ queue.push(message);
34346
+ pump();
34347
+ };
34348
+ wire.onerror = (error62) => {
34349
+ reportError(error62);
34350
+ if (state.phase === "probe" || state.phase === "pinned") state.instance.channel.onerror?.(error62);
34351
+ };
34352
+ wire.onclose = () => {
34353
+ if (closing || state.phase === "closed") return;
34354
+ closing = true;
34355
+ const current = state;
34356
+ state = { phase: "closed" };
34357
+ if (current.phase === "probe" || current.phase === "pinned") current.instance.product.close().catch((error62) => reportError(toError(error62)));
34358
+ };
34359
+ const started = wire.start().catch((error62) => {
34360
+ reportError(toError(error62));
34361
+ throw error62;
34362
+ });
34363
+ started.catch(() => {
34364
+ });
34365
+ return { close: async () => {
34366
+ await started.catch(() => {
34367
+ });
34368
+ await closeAll();
34369
+ } };
34370
+ }
34371
+ function toError(value) {
34372
+ return value instanceof Error ? value : new Error(String(value));
34373
+ }
33702
34374
 
33703
34375
  // ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
33704
34376
  var McpToolError = class extends Error {
@@ -33880,17 +34552,41 @@ Hint: ${err.hint}`);
33880
34552
  }
33881
34553
  throw err;
33882
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
+ }
33883
34565
  function surfaceToolHints(server) {
33884
34566
  const register2 = server.registerTool.bind(server);
33885
- server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => {
33886
- let result;
33887
- try {
33888
- result = cb(...args);
33889
- } catch (err) {
33890
- return hintResultOrRethrow(err);
33891
- }
33892
- return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
33893
- });
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
+ ));
33894
34590
  }
33895
34591
  async function createMcpServer(opts) {
33896
34592
  const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
@@ -33905,7 +34601,7 @@ async function createMcpServer(opts) {
33905
34601
  }
33906
34602
  return server;
33907
34603
  }
33908
- function withGracefulShutdown(server, opts = {}) {
34604
+ function withGracefulShutdown(target, opts = {}) {
33909
34605
  const shouldExit = opts.exit ?? true;
33910
34606
  let shuttingDown = false;
33911
34607
  const handler = (signal) => {
@@ -33916,7 +34612,7 @@ function withGracefulShutdown(server, opts = {}) {
33916
34612
  try {
33917
34613
  if (opts.onSignal)
33918
34614
  await opts.onSignal(signal);
33919
- await server.close();
34615
+ await target.close();
33920
34616
  } catch (err) {
33921
34617
  console.error(`[mcp-utils] error during graceful shutdown on ${signal}: ${err instanceof Error ? err.message : String(err)}`);
33922
34618
  } finally {
@@ -33928,16 +34624,26 @@ function withGracefulShutdown(server, opts = {}) {
33928
34624
  process.on("SIGINT", () => handler("SIGINT"));
33929
34625
  process.on("SIGTERM", () => handler("SIGTERM"));
33930
34626
  }
33931
- async function runMcp(opts) {
33932
- const server = await createMcpServer(opts);
34627
+ function runMcp(opts) {
34628
+ if (opts.banner !== void 0) {
34629
+ console.error(opts.banner);
34630
+ }
34631
+ const spec = opts.transport ?? "stdio";
34632
+ const handle = serveStdio(() => createMcpServer({ ...opts, banner: void 0 }), {
34633
+ legacy: opts.legacy ?? "serve",
34634
+ onerror: opts.onerror ?? ((error62) => {
34635
+ console.error(`[mcp-utils] stdio server error: ${redactSecrets(error62.message)}`);
34636
+ }),
34637
+ // `'stdio'` means "let the entry make its own StdioServerTransport over
34638
+ // this process"; anything else is the caller's transport, which the entry
34639
+ // then owns (it starts it, pumps it, and closes it with the handle).
34640
+ ...spec === "stdio" ? {} : { transport: spec }
34641
+ });
33933
34642
  const shutdown = opts.shutdown ?? true;
33934
34643
  if (shutdown !== false) {
33935
- withGracefulShutdown(server, shutdown === true ? {} : shutdown);
34644
+ withGracefulShutdown(handle, shutdown === true ? {} : shutdown);
33936
34645
  }
33937
- const spec = opts.transport ?? "stdio";
33938
- const transport = spec === "stdio" ? new StdioServerTransport() : spec;
33939
- await server.connect(transport);
33940
- return server;
34646
+ return handle;
33941
34647
  }
33942
34648
 
33943
34649
  // ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
@@ -34315,8 +35021,15 @@ async function spawnGog(fullArgs, opts) {
34315
35021
  const stdoutChunks = [];
34316
35022
  const stderrChunks = [];
34317
35023
  let settled = false;
35024
+ const cancelled = currentCallSignal();
35025
+ const stopWatchingCancel = killOnCancel(child);
35026
+ const stopWatching = () => {
35027
+ clearTimeout(timer);
35028
+ stopWatchingCancel();
35029
+ };
34318
35030
  const timer = setTimeout(() => {
34319
35031
  settled = true;
35032
+ stopWatchingCancel();
34320
35033
  child.kill();
34321
35034
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
34322
35035
  }, effectiveTimeout);
@@ -34327,9 +35040,13 @@ async function spawnGog(fullArgs, opts) {
34327
35040
  stderrChunks.push(chunk);
34328
35041
  });
34329
35042
  child.on("close", (code) => {
34330
- clearTimeout(timer);
35043
+ stopWatching();
34331
35044
  if (settled) return;
34332
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
+ }
34333
35050
  const stderr = Buffer.concat(stderrChunks).toString().trim();
34334
35051
  if (code === 0) {
34335
35052
  if (binary) {
@@ -34347,7 +35064,7 @@ async function spawnGog(fullArgs, opts) {
34347
35064
  }
34348
35065
  });
34349
35066
  child.on("error", (err) => {
34350
- clearTimeout(timer);
35067
+ stopWatching();
34351
35068
  if (settled) return;
34352
35069
  settled = true;
34353
35070
  if (err.code === "ENOENT") {
@@ -36865,7 +37582,7 @@ function registerTasksTools(server) {
36865
37582
  }
36866
37583
 
36867
37584
  // src/server.ts
36868
- var VERSION = true ? "4.0.1" : "0.0.0";
37585
+ var VERSION = true ? "4.2.0" : "0.0.0";
36869
37586
  var BASE_TOOL_REGISTRARS = [
36870
37587
  registerApiTools,
36871
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.0.1" : "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.0.1",
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",
@@ -524,7 +524,7 @@
524
524
  "win32"
525
525
  ],
526
526
  "runtimes": {
527
- "node": ">=18.0.0"
527
+ "node": ">=22.0.0"
528
528
  }
529
529
  }
530
530
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "4.0.1",
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": "^0.28.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.0.1",
10
+ "version": "4.2.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.0.1",
15
+ "version": "4.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -12,7 +12,7 @@ MCP server wrapping [gogcli](https://github.com/openclaw/gogcli) — provides Cl
12
12
  ## Requirements
13
13
 
14
14
  - [gogcli](https://github.com/openclaw/gogcli) installed and authenticated (`gog --help` works in your shell)
15
- - Node.js 18 or later
15
+ - Node.js 22 or later
16
16
 
17
17
  ## Setup
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
+ });