gogcli-mcp 4.1.0 → 4.2.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": "4.1.0"
10
+ "version": "4.2.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": "4.1.0",
18
+ "version": "4.2.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": "4.1.0",
4
+ "version": "4.2.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
@@ -5,6 +5,66 @@ 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, request) {
12
+ return signal ? storage.run({ signal, ...request ? { request } : {} }, 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
+
34
+ // ../../node_modules/@chrischall/mcp-utils/dist/caller/index.js
35
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
36
+ var storage2 = new AsyncLocalStorage2();
37
+ function withCallerCapabilities(capabilities, fn) {
38
+ return capabilities ? storage2.run(capabilities, fn) : fn();
39
+ }
40
+ function currentCallerCapabilities() {
41
+ return storage2.getStore();
42
+ }
43
+ var ENVELOPE_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities";
44
+ var ELICITATION_MODES = ["form", "url"];
45
+ function isRecord(value) {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+ function callerCapabilities(ctx) {
49
+ const envelope = isRecord(ctx) && isRecord(ctx.mcpReq) ? ctx.mcpReq.envelope : void 0;
50
+ if (isRecord(envelope)) {
51
+ const declared = envelope[ENVELOPE_CAPABILITIES_KEY];
52
+ if (isRecord(declared))
53
+ return declared;
54
+ }
55
+ return currentCallerCapabilities();
56
+ }
57
+ function callerAcceptsFormElicitation(ctx) {
58
+ const capabilities = callerCapabilities(ctx);
59
+ if (!capabilities)
60
+ return void 0;
61
+ const elicitation = capabilities.elicitation;
62
+ if (!isRecord(elicitation))
63
+ return false;
64
+ const namedModes = ELICITATION_MODES.filter((mode) => mode in elicitation);
65
+ return namedModes.length === 0 || namedModes.includes("form");
66
+ }
67
+
8
68
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
9
69
  var __create = Object.create;
10
70
  var __defProp2 = Object.defineProperty;
@@ -34480,6 +34540,7 @@ function errorResult(message) {
34480
34540
  // ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
34481
34541
  var DEFAULT_REQUEST_KEY = "confirmation";
34482
34542
  var DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
34543
+ var UNSUPPORTED_NOTE = "Nothing was done because this client cannot show a confirmation prompt (it declares no MCP elicitation capability), and this action is never taken without one";
34483
34544
  function requireConfirmation(ctx, options) {
34484
34545
  const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
34485
34546
  const confirmationSchema = external_exports.object({
@@ -34488,6 +34549,15 @@ function requireConfirmation(ctx, options) {
34488
34549
  const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
34489
34550
  const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
34490
34551
  if (response.kind === "missing") {
34552
+ if (callerAcceptsFormElicitation(ctx) === false) {
34553
+ return textResult({
34554
+ confirmed: false,
34555
+ dispatched: false,
34556
+ action: options.action,
34557
+ reason: "confirmation-unsupported",
34558
+ note: options.unsupportedNote ? `${UNSUPPORTED_NOTE}. ${options.unsupportedNote}` : `${UNSUPPORTED_NOTE}.`
34559
+ });
34560
+ }
34491
34561
  const preview = {
34492
34562
  action: options.action,
34493
34563
  ...options.details === void 0 ? {} : { details: options.details }
@@ -34526,17 +34596,73 @@ Hint: ${err.hint}`);
34526
34596
  }
34527
34597
  throw err;
34528
34598
  }
34599
+ function mcpRequestFrom(args) {
34600
+ const ctx = args.at(-1);
34601
+ if (typeof ctx !== "object" || ctx === null)
34602
+ return void 0;
34603
+ const req = ctx.mcpReq;
34604
+ if (typeof req !== "object" || req === null)
34605
+ return void 0;
34606
+ return typeof req.notify === "function" ? req : void 0;
34607
+ }
34608
+ function callSignalFrom(args) {
34609
+ const ctx = args.at(-1);
34610
+ if (typeof ctx !== "object" || ctx === null)
34611
+ return void 0;
34612
+ const req = ctx.mcpReq;
34613
+ if (typeof req !== "object" || req === null)
34614
+ return void 0;
34615
+ const signal = req.signal;
34616
+ return signal instanceof AbortSignal ? signal : void 0;
34617
+ }
34618
+ function declaredCapabilitiesFrom(server) {
34619
+ const inner = server.server;
34620
+ if (typeof inner !== "object" || inner === null)
34621
+ return void 0;
34622
+ const read = inner.getClientCapabilities;
34623
+ if (typeof read !== "function")
34624
+ return void 0;
34625
+ try {
34626
+ const declared = read.call(inner);
34627
+ return typeof declared === "object" && declared !== null ? declared : void 0;
34628
+ } catch {
34629
+ return void 0;
34630
+ }
34631
+ }
34529
34632
  function surfaceToolHints(server) {
34530
34633
  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
- });
34634
+ server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
34635
+ // THE CALLER'S CANCELLATION, made ambient for the whole handler
34636
+ // (`cancel/index.ts`). The SDK delivers it and the fleet ignored it:
34637
+ // measured against @modelcontextprotocol/server 2.0.0, a cancelled
34638
+ // call aborts `ctx.mcpReq.signal` with the caller's reason and the
34639
+ // handler runs to completion regardless — so the HTTP request stays
34640
+ // in flight, the child keeps burning metered CPU, and the upstream
34641
+ // keeps being hit for somebody who has gone. claude.ai sent 101
34642
+ // cancellations in the week to 2026-09-20.
34643
+ //
34644
+ // Here because this is the one wrapper every tool already passes
34645
+ // through: threading the signal by hand would mean editing several
34646
+ // hundred handlers and missing exactly the ones nobody edits.
34647
+ withCallSignal(callSignalFrom(args), () => (
34648
+ // WHAT THE CALLER CAN DO, made ambient for the same reason and in
34649
+ // the same place (`caller/index.ts`). Only the 2025-era half needs
34650
+ // the wrapper — a 2026-07-28 request answers for itself off its own
34651
+ // envelope — but a guarded tool that cannot tell whether the caller
34652
+ // can be shown a prompt returns one the SDK then refuses to deliver,
34653
+ // and that refusal reaches the caller as an unexplained protocol
34654
+ // error it had no chance to catch.
34655
+ withCallerCapabilities(declaredCapabilitiesFrom(server), () => {
34656
+ let result;
34657
+ try {
34658
+ result = cb(...args);
34659
+ } catch (err) {
34660
+ return hintResultOrRethrow(err);
34661
+ }
34662
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
34663
+ })
34664
+ ), mcpRequestFrom(args))
34665
+ ));
34540
34666
  }
34541
34667
  async function createMcpServer(opts) {
34542
34668
  const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
@@ -34971,8 +35097,15 @@ async function spawnGog(fullArgs, opts) {
34971
35097
  const stdoutChunks = [];
34972
35098
  const stderrChunks = [];
34973
35099
  let settled = false;
35100
+ const cancelled = currentCallSignal();
35101
+ const stopWatchingCancel = killOnCancel(child);
35102
+ const stopWatching = () => {
35103
+ clearTimeout(timer);
35104
+ stopWatchingCancel();
35105
+ };
34974
35106
  const timer = setTimeout(() => {
34975
35107
  settled = true;
35108
+ stopWatchingCancel();
34976
35109
  child.kill();
34977
35110
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
34978
35111
  }, effectiveTimeout);
@@ -34983,9 +35116,13 @@ async function spawnGog(fullArgs, opts) {
34983
35116
  stderrChunks.push(chunk);
34984
35117
  });
34985
35118
  child.on("close", (code) => {
34986
- clearTimeout(timer);
35119
+ stopWatching();
34987
35120
  if (settled) return;
34988
35121
  settled = true;
35122
+ if (cancelled?.aborted) {
35123
+ reject(cancelled.reason instanceof Error ? cancelled.reason : new Error("the caller cancelled this call"));
35124
+ return;
35125
+ }
34989
35126
  const stderr = Buffer.concat(stderrChunks).toString().trim();
34990
35127
  if (code === 0) {
34991
35128
  if (binary) {
@@ -35003,7 +35140,7 @@ async function spawnGog(fullArgs, opts) {
35003
35140
  }
35004
35141
  });
35005
35142
  child.on("error", (err) => {
35006
- clearTimeout(timer);
35143
+ stopWatching();
35007
35144
  if (settled) return;
35008
35145
  settled = true;
35009
35146
  if (err.code === "ENOENT") {
@@ -36933,12 +37070,25 @@ function finish(base, itemsKey, merged, token) {
36933
37070
  }
36934
37071
 
36935
37072
  // src/gmail-dispatch-guard.ts
37073
+ function replyDispatchOp(kind) {
37074
+ return `gmail.${kind}`;
37075
+ }
37076
+ var STAGING_TWIN = {
37077
+ "gmail.forward": "gog_gmail_drafts_forward",
37078
+ "gmail.reply": "gog_gmail_drafts_reply",
37079
+ "gmail.reply-all": "gog_gmail_drafts_reply_all",
37080
+ "gmail.send": "gog_gmail_drafts_create"
37081
+ };
36936
37082
  function requireGmailDispatchConfirmation(ctx, op, details) {
37083
+ const twin = STAGING_TWIN[op];
36937
37084
  return requireConfirmation(ctx, {
36938
37085
  action: op,
36939
37086
  message: "Review and confirm this email dispatch:",
36940
37087
  details,
36941
- confirmationLabel: "Confirm that this email should be sent now."
37088
+ confirmationLabel: "Confirm that this email should be sent now.",
37089
+ ...twin ? {
37090
+ unsupportedNote: `Stage it with ${twin} instead, review the draft, and send it with gog_gmail_drafts_send.`
37091
+ } : {}
36942
37092
  });
36943
37093
  }
36944
37094
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -37067,7 +37217,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
37067
37217
  if (metaResult.isError) return metaResult;
37068
37218
  const headers = parseMetadataHeaders(resultText(metaResult));
37069
37219
  const recipients = computeReplyRecipients(kind, headers, flags);
37070
- const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
37220
+ const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
37071
37221
  messageId,
37072
37222
  recipients,
37073
37223
  recipientCount: recipients.length,
@@ -37521,7 +37671,7 @@ function registerTasksTools(server) {
37521
37671
  }
37522
37672
 
37523
37673
  // src/server.ts
37524
- var VERSION = true ? "4.1.0" : "0.0.0";
37674
+ var VERSION = true ? "4.2.1" : "0.0.0";
37525
37675
  var BASE_TOOL_REGISTRARS = [
37526
37676
  registerApiTools,
37527
37677
  registerAppScriptTools,
package/dist/lib.js CHANGED
@@ -19672,6 +19672,60 @@ 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
+
19698
+ // ../../node_modules/@chrischall/mcp-utils/dist/caller/index.js
19699
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
19700
+ var storage2 = new AsyncLocalStorage2();
19701
+ function currentCallerCapabilities() {
19702
+ return storage2.getStore();
19703
+ }
19704
+ var ENVELOPE_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities";
19705
+ var ELICITATION_MODES = ["form", "url"];
19706
+ function isRecord(value) {
19707
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19708
+ }
19709
+ function callerCapabilities(ctx) {
19710
+ const envelope = isRecord(ctx) && isRecord(ctx.mcpReq) ? ctx.mcpReq.envelope : void 0;
19711
+ if (isRecord(envelope)) {
19712
+ const declared = envelope[ENVELOPE_CAPABILITIES_KEY];
19713
+ if (isRecord(declared))
19714
+ return declared;
19715
+ }
19716
+ return currentCallerCapabilities();
19717
+ }
19718
+ function callerAcceptsFormElicitation(ctx) {
19719
+ const capabilities = callerCapabilities(ctx);
19720
+ if (!capabilities)
19721
+ return void 0;
19722
+ const elicitation = capabilities.elicitation;
19723
+ if (!isRecord(elicitation))
19724
+ return false;
19725
+ const namedModes = ELICITATION_MODES.filter((mode) => mode in elicitation);
19726
+ return namedModes.length === 0 || namedModes.includes("form");
19727
+ }
19728
+
19675
19729
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
19676
19730
  var __create = Object.create;
19677
19731
  var __defProp2 = Object.defineProperty;
@@ -32949,6 +33003,7 @@ function errorResult(message) {
32949
33003
  // ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
32950
33004
  var DEFAULT_REQUEST_KEY = "confirmation";
32951
33005
  var DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
33006
+ var UNSUPPORTED_NOTE = "Nothing was done because this client cannot show a confirmation prompt (it declares no MCP elicitation capability), and this action is never taken without one";
32952
33007
  function requireConfirmation(ctx, options) {
32953
33008
  const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
32954
33009
  const confirmationSchema = external_exports.object({
@@ -32957,6 +33012,15 @@ function requireConfirmation(ctx, options) {
32957
33012
  const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
32958
33013
  const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
32959
33014
  if (response.kind === "missing") {
33015
+ if (callerAcceptsFormElicitation(ctx) === false) {
33016
+ return textResult({
33017
+ confirmed: false,
33018
+ dispatched: false,
33019
+ action: options.action,
33020
+ reason: "confirmation-unsupported",
33021
+ note: options.unsupportedNote ? `${UNSUPPORTED_NOTE}. ${options.unsupportedNote}` : `${UNSUPPORTED_NOTE}.`
33022
+ });
33023
+ }
32960
33024
  const preview = {
32961
33025
  action: options.action,
32962
33026
  ...options.details === void 0 ? {} : { details: options.details }
@@ -33364,8 +33428,15 @@ async function spawnGog(fullArgs, opts) {
33364
33428
  const stdoutChunks = [];
33365
33429
  const stderrChunks = [];
33366
33430
  let settled = false;
33431
+ const cancelled = currentCallSignal();
33432
+ const stopWatchingCancel = killOnCancel(child);
33433
+ const stopWatching = () => {
33434
+ clearTimeout(timer);
33435
+ stopWatchingCancel();
33436
+ };
33367
33437
  const timer = setTimeout(() => {
33368
33438
  settled = true;
33439
+ stopWatchingCancel();
33369
33440
  child.kill();
33370
33441
  reject(new Error(`gog timed out after ${formatTimeout(effectiveTimeout)}`));
33371
33442
  }, effectiveTimeout);
@@ -33376,9 +33447,13 @@ async function spawnGog(fullArgs, opts) {
33376
33447
  stderrChunks.push(chunk);
33377
33448
  });
33378
33449
  child.on("close", (code) => {
33379
- clearTimeout(timer);
33450
+ stopWatching();
33380
33451
  if (settled) return;
33381
33452
  settled = true;
33453
+ if (cancelled?.aborted) {
33454
+ reject(cancelled.reason instanceof Error ? cancelled.reason : new Error("the caller cancelled this call"));
33455
+ return;
33456
+ }
33382
33457
  const stderr = Buffer.concat(stderrChunks).toString().trim();
33383
33458
  if (code === 0) {
33384
33459
  if (binary) {
@@ -33396,7 +33471,7 @@ async function spawnGog(fullArgs, opts) {
33396
33471
  }
33397
33472
  });
33398
33473
  child.on("error", (err) => {
33399
- clearTimeout(timer);
33474
+ stopWatching();
33400
33475
  if (settled) return;
33401
33476
  settled = true;
33402
33477
  if (err.code === "ENOENT") {
@@ -35329,12 +35404,25 @@ function finish(base, itemsKey, merged, token) {
35329
35404
  }
35330
35405
 
35331
35406
  // src/gmail-dispatch-guard.ts
35407
+ function replyDispatchOp(kind) {
35408
+ return `gmail.${kind}`;
35409
+ }
35410
+ var STAGING_TWIN = {
35411
+ "gmail.forward": "gog_gmail_drafts_forward",
35412
+ "gmail.reply": "gog_gmail_drafts_reply",
35413
+ "gmail.reply-all": "gog_gmail_drafts_reply_all",
35414
+ "gmail.send": "gog_gmail_drafts_create"
35415
+ };
35332
35416
  function requireGmailDispatchConfirmation(ctx, op, details) {
35417
+ const twin = STAGING_TWIN[op];
35333
35418
  return requireConfirmation(ctx, {
35334
35419
  action: op,
35335
35420
  message: "Review and confirm this email dispatch:",
35336
35421
  details,
35337
- confirmationLabel: "Confirm that this email should be sent now."
35422
+ confirmationLabel: "Confirm that this email should be sent now.",
35423
+ ...twin ? {
35424
+ unsupportedNote: `Stage it with ${twin} instead, review the draft, and send it with gog_gmail_drafts_send.`
35425
+ } : {}
35338
35426
  });
35339
35427
  }
35340
35428
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -35463,7 +35551,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
35463
35551
  if (metaResult.isError) return metaResult;
35464
35552
  const headers = parseMetadataHeaders(resultText(metaResult));
35465
35553
  const recipients = computeReplyRecipients(kind, headers, flags);
35466
- const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
35554
+ const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
35467
35555
  messageId,
35468
35556
  recipients,
35469
35557
  recipientCount: recipients.length,
@@ -35917,7 +36005,7 @@ function registerTasksTools(server) {
35917
36005
  }
35918
36006
 
35919
36007
  // src/server.ts
35920
- var VERSION = true ? "4.1.0" : "0.0.0";
36008
+ var VERSION = true ? "4.2.1" : "0.0.0";
35921
36009
  var BASE_TOOL_REGISTRARS = [
35922
36010
  registerApiTools,
35923
36011
  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.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": "4.1.0",
3
+ "version": "4.2.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>",
@@ -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.1.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.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.1.0",
15
+ "version": "4.2.1",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -14,18 +14,93 @@ import { readEnvVar, requireConfirmation } from '@chrischall/mcp-utils';
14
14
  // result containing the preview, and only the protocol retry carrying the
15
15
  // user's accepted confirmation dispatches. The confirmation is never a tool
16
16
  // argument, so a model cannot bypass the user by setting a boolean itself.
17
+ //
18
+ // A CLIENT THAT CANNOT SHOW THAT PROMPT gets a sentence rather than a prompt
19
+ // it will refuse to deliver (`unsupportedNote`, mcp-utils `requireConfirmation`).
20
+ // Measured on the mcp-host fleet 2026-09-20: claude.ai declares no MCP
21
+ // elicitation capability, so from the day this rail shipped every one of these
22
+ // five tools answered it with a -32021 the surface renders as "Error occurred
23
+ // during tool execution" — four `gog_gmail_forward` attempts in a row, 69-107ms
24
+ // each, none of which reached `gog`. The rail is unchanged; what changes is that
25
+ // the refusal now says what happened and names the way through.
17
26
  // ============================================================================
27
+
28
+ /**
29
+ * The five dispatches this rail guards, spelled once.
30
+ *
31
+ * A UNION rather than `string`, because the staging-twin table below is keyed
32
+ * by these values and a key that matches no call site is silent: it costs the
33
+ * note, not the refusal, so nothing fails and no test that writes the op by
34
+ * hand can see it. `gmail.reply_all` shipped in the first cut of this file for
35
+ * exactly that reason — `sendReply` builds `gmail.${kind}` from
36
+ * `'reply' | 'reply-all'`, so the real op is HYPHENATED and the underscored key
37
+ * was never read. With the union in place that is a compile error at both ends.
38
+ */
39
+ export type GmailDispatchOp =
40
+ | 'gmail.send'
41
+ | 'gmail.reply'
42
+ | 'gmail.reply-all'
43
+ | 'gmail.forward'
44
+ | 'gmail.autoreply';
45
+
46
+ /** Every op, for tests that must cover the set rather than a chosen member. */
47
+ export const GMAIL_DISPATCH_OPS: readonly GmailDispatchOp[] = [
48
+ 'gmail.send',
49
+ 'gmail.reply',
50
+ 'gmail.reply-all',
51
+ 'gmail.forward',
52
+ 'gmail.autoreply',
53
+ ];
54
+
55
+ /**
56
+ * The op a reply dispatch reports, derived from the same `kind` the command
57
+ * line is built from.
58
+ *
59
+ * Exported so the ONE place that interpolates a kind into an op is the one a
60
+ * test can call, rather than a template literal inside `sendReply` that a test
61
+ * can only imitate. Imitating it is what hid the hyphen.
62
+ */
63
+ export function replyDispatchOp(kind: 'reply' | 'reply-all'): GmailDispatchOp {
64
+ return `gmail.${kind}`;
65
+ }
66
+
67
+ /**
68
+ * The staging twin of each dispatch, named in the refusal above. Every one of
69
+ * these saves without sending, and `gog_gmail_drafts_send` then dispatches it —
70
+ * which is the rail's own sanctioned two-step (staging is visible and
71
+ * inspectable, so the send is never the FIRST call), not a way around it.
72
+ *
73
+ * `Partial<Record<…>>` and not an index signature: a key outside the union is
74
+ * now rejected by the compiler, which is the whole point, while `autoreply`
75
+ * stays deliberately absent — a bulk auto-reply over a search has no draft
76
+ * twin, and naming a tool that does not exist would be worse than saying
77
+ * nothing.
78
+ */
79
+ const STAGING_TWIN: Partial<Record<GmailDispatchOp, string>> = {
80
+ 'gmail.forward': 'gog_gmail_drafts_forward',
81
+ 'gmail.reply': 'gog_gmail_drafts_reply',
82
+ 'gmail.reply-all': 'gog_gmail_drafts_reply_all',
83
+ 'gmail.send': 'gog_gmail_drafts_create',
84
+ };
85
+
18
86
  /** Apply the shared stateless confirmation flow with Gmail-specific copy. */
19
87
  export function requireGmailDispatchConfirmation(
20
88
  ctx: ServerContext,
21
- op: string,
89
+ op: GmailDispatchOp,
22
90
  details: Record<string, unknown>,
23
91
  ): InputRequiredResult | CallToolResult | undefined {
92
+ const twin = STAGING_TWIN[op];
24
93
  return requireConfirmation(ctx, {
25
94
  action: op,
26
95
  message: 'Review and confirm this email dispatch:',
27
96
  details,
28
97
  confirmationLabel: 'Confirm that this email should be sent now.',
98
+ ...(twin
99
+ ? {
100
+ unsupportedNote: `Stage it with ${twin} instead, review the draft, `
101
+ + 'and send it with gog_gmail_drafts_send.',
102
+ }
103
+ : {}),
29
104
  });
30
105
  }
31
106
 
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') {
@@ -5,7 +5,7 @@ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
5
5
  import type { GogArg } from '../runner.js';
6
6
  import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
7
7
  import type { InlineAttachmentInput } from '../attachments.js';
8
- import { extractEmails, logGmailDispatch, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
8
+ import { extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
9
9
 
10
10
  // gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
11
11
  // only in the subcommand and default recipient set (reply → sender; reply-all
@@ -154,7 +154,7 @@ async function sendReply(
154
154
  if (metaResult.isError) return metaResult;
155
155
  const headers = parseMetadataHeaders(resultText(metaResult));
156
156
  const recipients = computeReplyRecipients(kind, headers, flags);
157
- const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
157
+ const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
158
158
  messageId,
159
159
  recipients,
160
160
  recipientCount: recipients.length,
@@ -1,10 +1,15 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { rawTextResult } from '@chrischall/mcp-utils';
3
+ import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';
3
4
  import {
5
+ GMAIL_DISPATCH_OPS,
4
6
  extractEmails,
5
7
  logGmailDispatch,
8
+ replyDispatchOp,
9
+ requireGmailDispatchConfirmation,
6
10
  resultText,
7
11
  } from '../src/gmail-dispatch-guard.js';
12
+ import type { GmailDispatchOp } from '../src/gmail-dispatch-guard.js';
8
13
 
9
14
  describe('extractEmails', () => {
10
15
  it('extracts a bare address', () => {
@@ -130,3 +135,91 @@ describe('logGmailDispatch', () => {
130
135
  expect(event.externalRecipients).toEqual(['not-an-email']);
131
136
  });
132
137
  });
138
+
139
+ // ============================================================================
140
+ // The refusal a client that cannot be asked gets. From #358 until this landed
141
+ // it got a protocol -32021 instead, which claude.ai — which declares no MCP
142
+ // elicitation capability — rendered as "Error occurred during tool execution".
143
+ // ============================================================================
144
+ /** A 2026-07-28 request whose envelope declares the CALLER's capabilities. */
145
+ function ctxDeclaring(capabilities: unknown): ServerContext {
146
+ return {
147
+ mcpReq: {
148
+ envelope: {
149
+ 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
150
+ 'io.modelcontextprotocol/clientCapabilities': capabilities,
151
+ },
152
+ },
153
+ } as unknown as ServerContext;
154
+ }
155
+
156
+ /** claude.ai's measured shape: extensions and nothing else. */
157
+ const CANNOT_BE_ASKED = ctxDeclaring({ extensions: {} });
158
+
159
+ function refusalNote(op: GmailDispatchOp): string {
160
+ const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, op, {}) as CallToolResult;
161
+ return JSON.parse(resultText(result)).note as string;
162
+ }
163
+
164
+ /**
165
+ * The twin each op must name. Written against `GMAIL_DISPATCH_OPS` rather than
166
+ * as a list of its own, so a sixth dispatch added later fails here until
167
+ * somebody decides whether it stages — `null` being the deliberate "it does
168
+ * not" that `gmail.autoreply` is the only member of today.
169
+ */
170
+ const EXPECTED_TWIN: Record<GmailDispatchOp, string | null> = {
171
+ 'gmail.send': 'gog_gmail_drafts_create',
172
+ 'gmail.reply': 'gog_gmail_drafts_reply',
173
+ 'gmail.reply-all': 'gog_gmail_drafts_reply_all',
174
+ 'gmail.forward': 'gog_gmail_drafts_forward',
175
+ 'gmail.autoreply': null,
176
+ };
177
+
178
+ describe('requireGmailDispatchConfirmation on a client that cannot be asked', () => {
179
+ it.each(GMAIL_DISPATCH_OPS.map((op) => [op, EXPECTED_TWIN[op]] as const))(
180
+ 'answers %s with its staging twin %s',
181
+ (op, twin) => {
182
+ const note = refusalNote(op);
183
+ expect(note).toContain('cannot show a confirmation prompt');
184
+ if (twin === null) {
185
+ // A bulk auto-reply over a search has no draft twin, and naming a tool
186
+ // that does not exist would be worse than saying nothing.
187
+ expect(note).not.toContain('Stage it with');
188
+ } else {
189
+ expect(note).toContain(`Stage it with ${twin} instead`);
190
+ expect(note).toContain('gog_gmail_drafts_send');
191
+ }
192
+ },
193
+ );
194
+
195
+ // THE OP COMES FROM THE CALL PATH, NOT FROM THIS FILE. `sendReply` builds it
196
+ // as `gmail.${kind}` off the same `'reply' | 'reply-all'` union the command
197
+ // line uses, so the first cut's `gmail.reply_all` key matched nothing and
198
+ // reply-all silently lost its note — invisible to a test that spelled the op
199
+ // itself. Going through the helper the call site now calls is what closes it.
200
+ it.each([
201
+ ['reply', 'gog_gmail_drafts_reply'],
202
+ ['reply-all', 'gog_gmail_drafts_reply_all'],
203
+ ] as const)('derives the %s op from the real call path', (kind, twin) => {
204
+ expect(refusalNote(replyDispatchOp(kind))).toContain(`Stage it with ${twin} instead`);
205
+ });
206
+
207
+ it('refuses rather than dispatching, and says so in the payload', () => {
208
+ const result = requireGmailDispatchConfirmation(CANNOT_BE_ASKED, 'gmail.forward', {
209
+ messageId: 'm1',
210
+ to: 'someone@example.com',
211
+ }) as CallToolResult;
212
+
213
+ expect(JSON.parse(resultText(result))).toMatchObject({
214
+ confirmed: false,
215
+ dispatched: false,
216
+ action: 'gmail.forward',
217
+ reason: 'confirmation-unsupported',
218
+ });
219
+ });
220
+
221
+ it('still asks a client that declares form elicitation', () => {
222
+ expect(requireGmailDispatchConfirmation(ctxDeclaring({ elicitation: { form: {} } }), 'gmail.forward', {}))
223
+ .toMatchObject({ resultType: 'input_required' });
224
+ });
225
+ });
@@ -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
+ });