gogcli-mcp 4.2.0 → 4.2.2

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.2.0"
10
+ "version": "4.2.2"
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.2.0",
18
+ "version": "4.2.2",
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.2.0",
4
+ "version": "4.2.2",
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
@@ -8,8 +8,8 @@ var __export = (target, all) => {
8
8
  // ../../node_modules/@chrischall/mcp-utils/dist/cancel/index.js
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  var storage = new AsyncLocalStorage();
11
- function withCallSignal(signal, fn) {
12
- return signal ? storage.run({ signal }, fn) : fn();
11
+ function withCallSignal(signal, fn, request) {
12
+ return signal ? storage.run({ signal, ...request ? { request } : {} }, fn) : fn();
13
13
  }
14
14
  function currentCallSignal() {
15
15
  return storage.getStore()?.signal;
@@ -31,6 +31,40 @@ function killOnCancel(child, signal = "SIGTERM") {
31
31
  return () => ambient.removeEventListener("abort", onAbort);
32
32
  }
33
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
+
34
68
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
35
69
  var __create = Object.create;
36
70
  var __defProp2 = Object.defineProperty;
@@ -34506,6 +34540,7 @@ function errorResult(message) {
34506
34540
  // ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
34507
34541
  var DEFAULT_REQUEST_KEY = "confirmation";
34508
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";
34509
34544
  function requireConfirmation(ctx, options) {
34510
34545
  const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
34511
34546
  const confirmationSchema = external_exports.object({
@@ -34514,6 +34549,15 @@ function requireConfirmation(ctx, options) {
34514
34549
  const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
34515
34550
  const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
34516
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
+ }
34517
34561
  const preview = {
34518
34562
  action: options.action,
34519
34563
  ...options.details === void 0 ? {} : { details: options.details }
@@ -34552,6 +34596,15 @@ Hint: ${err.hint}`);
34552
34596
  }
34553
34597
  throw err;
34554
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
+ }
34555
34608
  function callSignalFrom(args) {
34556
34609
  const ctx = args.at(-1);
34557
34610
  if (typeof ctx !== "object" || ctx === null)
@@ -34562,6 +34615,20 @@ function callSignalFrom(args) {
34562
34615
  const signal = req.signal;
34563
34616
  return signal instanceof AbortSignal ? signal : void 0;
34564
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
+ }
34565
34632
  function surfaceToolHints(server) {
34566
34633
  const register2 = server.registerTool.bind(server);
34567
34634
  server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
@@ -34577,15 +34644,24 @@ function surfaceToolHints(server) {
34577
34644
  // Here because this is the one wrapper every tool already passes
34578
34645
  // through: threading the signal by hand would mean editing several
34579
34646
  // 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
- })
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))
34589
34665
  ));
34590
34666
  }
34591
34667
  async function createMcpServer(opts) {
@@ -35594,6 +35670,7 @@ function registerAuthToolsWith(server, defaultServices) {
35594
35670
  });
35595
35671
  server.registerTool("gog_auth_add_url", {
35596
35672
  description: "Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any browser \u2014 no local server or terminal on the gogcli host is needed, so this works on a hosted deployment where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, the browser is redirected to a localhost URL that fails to load \u2014 that is expected. They copy that full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to gog_auth_add_complete or the second step will not match this one.",
35673
+ annotations: { readOnlyHint: true },
35597
35674
  inputSchema: external_exports.object({
35598
35675
  email: external_exports.string().describe("Google account email to authorize"),
35599
35676
  services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
@@ -36994,12 +37071,25 @@ function finish(base, itemsKey, merged, token) {
36994
37071
  }
36995
37072
 
36996
37073
  // src/gmail-dispatch-guard.ts
37074
+ function replyDispatchOp(kind) {
37075
+ return `gmail.${kind}`;
37076
+ }
37077
+ var STAGING_TWIN = {
37078
+ "gmail.forward": "gog_gmail_drafts_forward",
37079
+ "gmail.reply": "gog_gmail_drafts_reply",
37080
+ "gmail.reply-all": "gog_gmail_drafts_reply_all",
37081
+ "gmail.send": "gog_gmail_drafts_create"
37082
+ };
36997
37083
  function requireGmailDispatchConfirmation(ctx, op, details) {
37084
+ const twin = STAGING_TWIN[op];
36998
37085
  return requireConfirmation(ctx, {
36999
37086
  action: op,
37000
37087
  message: "Review and confirm this email dispatch:",
37001
37088
  details,
37002
- confirmationLabel: "Confirm that this email should be sent now."
37089
+ confirmationLabel: "Confirm that this email should be sent now.",
37090
+ ...twin ? {
37091
+ unsupportedNote: `Stage it with ${twin} instead, review the draft, and send it with gog_gmail_drafts_send.`
37092
+ } : {}
37003
37093
  });
37004
37094
  }
37005
37095
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -37128,7 +37218,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
37128
37218
  if (metaResult.isError) return metaResult;
37129
37219
  const headers = parseMetadataHeaders(resultText(metaResult));
37130
37220
  const recipients = computeReplyRecipients(kind, headers, flags);
37131
- const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
37221
+ const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
37132
37222
  messageId,
37133
37223
  recipients,
37134
37224
  recipientCount: recipients.length,
@@ -37582,7 +37672,7 @@ function registerTasksTools(server) {
37582
37672
  }
37583
37673
 
37584
37674
  // src/server.ts
37585
- var VERSION = true ? "4.2.0" : "0.0.0";
37675
+ var VERSION = true ? "4.2.2" : "0.0.0";
37586
37676
  var BASE_TOOL_REGISTRARS = [
37587
37677
  registerApiTools,
37588
37678
  registerAppScriptTools,
package/dist/lib.js CHANGED
@@ -19695,6 +19695,37 @@ function killOnCancel(child, signal = "SIGTERM") {
19695
19695
  return () => ambient.removeEventListener("abort", onAbort);
19696
19696
  }
19697
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
+
19698
19729
  // ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
19699
19730
  var __create = Object.create;
19700
19731
  var __defProp2 = Object.defineProperty;
@@ -32972,6 +33003,7 @@ function errorResult(message) {
32972
33003
  // ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
32973
33004
  var DEFAULT_REQUEST_KEY = "confirmation";
32974
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";
32975
33007
  function requireConfirmation(ctx, options) {
32976
33008
  const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
32977
33009
  const confirmationSchema = external_exports.object({
@@ -32980,6 +33012,15 @@ function requireConfirmation(ctx, options) {
32980
33012
  const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
32981
33013
  const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
32982
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
+ }
32983
33024
  const preview = {
32984
33025
  action: options.action,
32985
33026
  ...options.details === void 0 ? {} : { details: options.details }
@@ -33960,6 +34001,7 @@ function registerAuthToolsWith(server, defaultServices) {
33960
34001
  });
33961
34002
  server.registerTool("gog_auth_add_url", {
33962
34003
  description: "Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any browser \u2014 no local server or terminal on the gogcli host is needed, so this works on a hosted deployment where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, the browser is redirected to a localhost URL that fails to load \u2014 that is expected. They copy that full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to gog_auth_add_complete or the second step will not match this one.",
34004
+ annotations: { readOnlyHint: true },
33963
34005
  inputSchema: external_exports.object({
33964
34006
  email: external_exports.string().describe("Google account email to authorize"),
33965
34007
  services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
@@ -35363,12 +35405,25 @@ function finish(base, itemsKey, merged, token) {
35363
35405
  }
35364
35406
 
35365
35407
  // src/gmail-dispatch-guard.ts
35408
+ function replyDispatchOp(kind) {
35409
+ return `gmail.${kind}`;
35410
+ }
35411
+ var STAGING_TWIN = {
35412
+ "gmail.forward": "gog_gmail_drafts_forward",
35413
+ "gmail.reply": "gog_gmail_drafts_reply",
35414
+ "gmail.reply-all": "gog_gmail_drafts_reply_all",
35415
+ "gmail.send": "gog_gmail_drafts_create"
35416
+ };
35366
35417
  function requireGmailDispatchConfirmation(ctx, op, details) {
35418
+ const twin = STAGING_TWIN[op];
35367
35419
  return requireConfirmation(ctx, {
35368
35420
  action: op,
35369
35421
  message: "Review and confirm this email dispatch:",
35370
35422
  details,
35371
- confirmationLabel: "Confirm that this email should be sent now."
35423
+ confirmationLabel: "Confirm that this email should be sent now.",
35424
+ ...twin ? {
35425
+ unsupportedNote: `Stage it with ${twin} instead, review the draft, and send it with gog_gmail_drafts_send.`
35426
+ } : {}
35372
35427
  });
35373
35428
  }
35374
35429
  var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
@@ -35497,7 +35552,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
35497
35552
  if (metaResult.isError) return metaResult;
35498
35553
  const headers = parseMetadataHeaders(resultText(metaResult));
35499
35554
  const recipients = computeReplyRecipients(kind, headers, flags);
35500
- const confirmation = requireGmailDispatchConfirmation(ctx, `gmail.${kind}`, {
35555
+ const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
35501
35556
  messageId,
35502
35557
  recipients,
35503
35558
  recipientCount: recipients.length,
@@ -35951,7 +36006,7 @@ function registerTasksTools(server) {
35951
36006
  }
35952
36007
 
35953
36008
  // src/server.ts
35954
- var VERSION = true ? "4.2.0" : "0.0.0";
36009
+ var VERSION = true ? "4.2.2" : "0.0.0";
35955
36010
  var BASE_TOOL_REGISTRARS = [
35956
36011
  registerApiTools,
35957
36012
  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.2.0",
6
+ "version": "4.2.2",
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.2.0",
3
+ "version": "4.2.2",
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,7 +41,7 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^2.0.0",
44
+ "@chrischall/mcp-utils": "^2.1.0",
45
45
  "@modelcontextprotocol/server": "^2.0.0",
46
46
  "zod": "^4.6.1"
47
47
  },
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "4.2.0",
10
+ "version": "4.2.2",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "4.2.0",
15
+ "version": "4.2.2",
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/tools/auth.ts CHANGED
@@ -135,6 +135,7 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
135
135
  'full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is ' +
136
136
  'valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to ' +
137
137
  'gog_auth_add_complete or the second step will not match this one.',
138
+ annotations: { readOnlyHint: true },
138
139
  inputSchema: z.object({
139
140
  email: z.string().describe('Google account email to authorize'),
140
141
  services: z.string().optional().default(defaultServices).describe(servicesDescribe),
@@ -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
+ });