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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +104 -14
- package/dist/lib.js +58 -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/auth.ts +1 -0
- 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.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.
|
|
18
|
+
"version": "4.2.2",
|
|
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) {
|
|
@@ -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,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "4.2.
|
|
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:
|
|
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),
|
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
|
+
});
|