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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +164 -14
- package/dist/lib.js +93 -5
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/gmail-dispatch-guard.ts +76 -1
- package/src/runner.ts +29 -3
- package/src/tools/gmail.ts +2 -2
- package/tests/gmail-dispatch-guard.test.ts +93 -0
- package/tests/runner.test.ts +72 -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.1
|
|
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
|
|
18
|
+
"version": "4.2.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
-
|
|
34533
|
-
|
|
34534
|
-
|
|
34535
|
-
|
|
34536
|
-
|
|
34537
|
-
|
|
34538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
10
|
+
"version": "4.2.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.1
|
|
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/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
|
-
|
|
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
|
-
|
|
442
|
+
stopWatching();
|
|
417
443
|
if (settled) return;
|
|
418
444
|
settled = true;
|
|
419
445
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
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
|
+
});
|
package/tests/runner.test.ts
CHANGED
|
@@ -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
|
+
});
|