gogcli-mcp 4.2.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +103 -14
- package/dist/lib.js +57 -3
- package/manifest.json +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/gmail-dispatch-guard.ts +76 -1
- package/src/tools/gmail.ts +2 -2
- package/tests/gmail-dispatch-guard.test.ts +93 -0
|
@@ -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.
|
|
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.2.
|
|
18
|
+
"version": "4.2.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
-
|
|
34582
|
-
|
|
34583
|
-
|
|
34584
|
-
|
|
34585
|
-
|
|
34586
|
-
|
|
34587
|
-
|
|
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) {
|
|
@@ -36994,12 +37070,25 @@ function finish(base, itemsKey, merged, token) {
|
|
|
36994
37070
|
}
|
|
36995
37071
|
|
|
36996
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
|
+
};
|
|
36997
37082
|
function requireGmailDispatchConfirmation(ctx, op, details) {
|
|
37083
|
+
const twin = STAGING_TWIN[op];
|
|
36998
37084
|
return requireConfirmation(ctx, {
|
|
36999
37085
|
action: op,
|
|
37000
37086
|
message: "Review and confirm this email dispatch:",
|
|
37001
37087
|
details,
|
|
37002
|
-
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
|
+
} : {}
|
|
37003
37092
|
});
|
|
37004
37093
|
}
|
|
37005
37094
|
var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
|
|
@@ -37128,7 +37217,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
|
|
|
37128
37217
|
if (metaResult.isError) return metaResult;
|
|
37129
37218
|
const headers = parseMetadataHeaders(resultText(metaResult));
|
|
37130
37219
|
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
37131
|
-
const confirmation = requireGmailDispatchConfirmation(ctx,
|
|
37220
|
+
const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
37132
37221
|
messageId,
|
|
37133
37222
|
recipients,
|
|
37134
37223
|
recipientCount: recipients.length,
|
|
@@ -37582,7 +37671,7 @@ function registerTasksTools(server) {
|
|
|
37582
37671
|
}
|
|
37583
37672
|
|
|
37584
37673
|
// src/server.ts
|
|
37585
|
-
var VERSION = true ? "4.2.
|
|
37674
|
+
var VERSION = true ? "4.2.1" : "0.0.0";
|
|
37586
37675
|
var BASE_TOOL_REGISTRARS = [
|
|
37587
37676
|
registerApiTools,
|
|
37588
37677
|
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 }
|
|
@@ -35363,12 +35404,25 @@ function finish(base, itemsKey, merged, token) {
|
|
|
35363
35404
|
}
|
|
35364
35405
|
|
|
35365
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
|
+
};
|
|
35366
35416
|
function requireGmailDispatchConfirmation(ctx, op, details) {
|
|
35417
|
+
const twin = STAGING_TWIN[op];
|
|
35367
35418
|
return requireConfirmation(ctx, {
|
|
35368
35419
|
action: op,
|
|
35369
35420
|
message: "Review and confirm this email dispatch:",
|
|
35370
35421
|
details,
|
|
35371
|
-
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
|
+
} : {}
|
|
35372
35426
|
});
|
|
35373
35427
|
}
|
|
35374
35428
|
var EMAIL_PATTERN = /[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
|
|
@@ -35497,7 +35551,7 @@ async function sendReply(kind, toolName, messageId, account, flags, ctx) {
|
|
|
35497
35551
|
if (metaResult.isError) return metaResult;
|
|
35498
35552
|
const headers = parseMetadataHeaders(resultText(metaResult));
|
|
35499
35553
|
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
35500
|
-
const confirmation = requireGmailDispatchConfirmation(ctx,
|
|
35554
|
+
const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
35501
35555
|
messageId,
|
|
35502
35556
|
recipients,
|
|
35503
35557
|
recipientCount: recipients.length,
|
|
@@ -35951,7 +36005,7 @@ function registerTasksTools(server) {
|
|
|
35951
36005
|
}
|
|
35952
36006
|
|
|
35953
36007
|
// src/server.ts
|
|
35954
|
-
var VERSION = true ? "4.2.
|
|
36008
|
+
var VERSION = true ? "4.2.1" : "0.0.0";
|
|
35955
36009
|
var BASE_TOOL_REGISTRARS = [
|
|
35956
36010
|
registerApiTools,
|
|
35957
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.2.
|
|
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.2.
|
|
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,7 +41,7 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^2.
|
|
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.
|
|
10
|
+
"version": "4.2.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.2.
|
|
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:
|
|
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/gmail.ts
CHANGED
|
@@ -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,
|
|
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
|
+
});
|