gogcli-mcp-sheets 4.4.0 → 4.5.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 +638 -59
- package/manifest.json +4 -4
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/tools/sheets-extra.ts +117 -5
- package/tests/tools/sheets-extra-gates.test.ts +158 -0
- package/tests/tools/sheets-extra.test.ts +4 -1
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.5.1"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "gogcli (Sheets)",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
18
|
-
"version": "4.
|
|
18
|
+
"version": "4.5.1",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,33 @@ var storage2 = new AsyncLocalStorage2();
|
|
|
37
37
|
function withCallerCapabilities(capabilities, fn) {
|
|
38
38
|
return capabilities ? storage2.run(capabilities, fn) : fn();
|
|
39
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
|
+
}
|
|
40
67
|
|
|
41
68
|
// ../../node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
|
|
42
69
|
var __create = Object.create;
|
|
@@ -24101,6 +24128,37 @@ var inputRequired = Object.assign(buildInputRequired, {
|
|
|
24101
24128
|
return { method: "roots/list" };
|
|
24102
24129
|
}
|
|
24103
24130
|
});
|
|
24131
|
+
function acceptedContent(responses, key, schema) {
|
|
24132
|
+
const view = inputResponse(responses, key);
|
|
24133
|
+
if (view.kind !== "elicit" || view.action !== "accept" || view.content === void 0) return void 0;
|
|
24134
|
+
if (schema === void 0) return view.content;
|
|
24135
|
+
const outcome = schema["~standard"].validate(view.content);
|
|
24136
|
+
if (outcome instanceof Promise) throw new TypeError("acceptedContent(responses, key, schema) requires a synchronously-validating schema");
|
|
24137
|
+
return outcome.issues === void 0 ? outcome.value : void 0;
|
|
24138
|
+
}
|
|
24139
|
+
function inputResponse(responses, key) {
|
|
24140
|
+
if (responses === void 0 || typeof responses !== "object" || responses === null) return { kind: "missing" };
|
|
24141
|
+
const entry = responses[key];
|
|
24142
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { kind: "missing" };
|
|
24143
|
+
const candidate = entry;
|
|
24144
|
+
if (candidate["action"] === "accept" || candidate["action"] === "decline" || candidate["action"] === "cancel") {
|
|
24145
|
+
const content = candidate["content"];
|
|
24146
|
+
return {
|
|
24147
|
+
kind: "elicit",
|
|
24148
|
+
action: candidate["action"],
|
|
24149
|
+
...content !== null && typeof content === "object" && !Array.isArray(content) && { content }
|
|
24150
|
+
};
|
|
24151
|
+
}
|
|
24152
|
+
if (Array.isArray(candidate["roots"])) return {
|
|
24153
|
+
kind: "roots",
|
|
24154
|
+
roots: candidate["roots"]
|
|
24155
|
+
};
|
|
24156
|
+
if (typeof candidate["role"] === "string" && candidate["content"] !== void 0) return {
|
|
24157
|
+
kind: "sampling",
|
|
24158
|
+
result: candidate
|
|
24159
|
+
};
|
|
24160
|
+
return { kind: "missing" };
|
|
24161
|
+
}
|
|
24104
24162
|
var REQUEST_STATE_ONLY_LEG_PACING_MS = 250;
|
|
24105
24163
|
function inputRequiredRoundsExceededMessage(method, maxRounds) {
|
|
24106
24164
|
return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
|
|
@@ -34148,14 +34206,14 @@ function serveStdio(factory, options = {}) {
|
|
|
34148
34206
|
return false;
|
|
34149
34207
|
};
|
|
34150
34208
|
const answerLegacyRejection = (request, reason, requestedVersion) => {
|
|
34151
|
-
const
|
|
34209
|
+
const rejection3 = modernOnlyStrictRejection({
|
|
34152
34210
|
kind: "legacy",
|
|
34153
34211
|
reason,
|
|
34154
34212
|
...requestedVersion !== void 0 && { requestedVersion }
|
|
34155
34213
|
}, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
|
|
34156
|
-
if (
|
|
34157
|
-
reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${
|
|
34158
|
-
return writeErrorResponse(request.id,
|
|
34214
|
+
if (rejection3 === void 0) return Promise.resolve();
|
|
34215
|
+
reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection3.cell}): ${rejection3.message}`));
|
|
34216
|
+
return writeErrorResponse(request.id, rejection3.code, rejection3.message, rejection3.data);
|
|
34159
34217
|
};
|
|
34160
34218
|
const onInstanceClosed = (channel) => {
|
|
34161
34219
|
if (closing || channel === discarding) return;
|
|
@@ -34458,6 +34516,11 @@ function walk(value, keep, drop) {
|
|
|
34458
34516
|
}
|
|
34459
34517
|
|
|
34460
34518
|
// ../../node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
34519
|
+
function textResult(data) {
|
|
34520
|
+
return {
|
|
34521
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
34522
|
+
};
|
|
34523
|
+
}
|
|
34461
34524
|
function rawTextResult(text) {
|
|
34462
34525
|
return { content: [{ type: "text", text }] };
|
|
34463
34526
|
}
|
|
@@ -34468,6 +34531,416 @@ function errorResult(message) {
|
|
|
34468
34531
|
};
|
|
34469
34532
|
}
|
|
34470
34533
|
|
|
34534
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
|
|
34535
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
34536
|
+
|
|
34537
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/canonical.js
|
|
34538
|
+
function canonicalJson(value) {
|
|
34539
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
34540
|
+
return JSON.stringify(value);
|
|
34541
|
+
if (value === void 0)
|
|
34542
|
+
return "null";
|
|
34543
|
+
if (typeof value === "number")
|
|
34544
|
+
return Number.isFinite(value) ? JSON.stringify(value) : `{"$num":"${String(value)}"}`;
|
|
34545
|
+
if (typeof value === "bigint")
|
|
34546
|
+
return `{"$bigint":"${value.toString()}"}`;
|
|
34547
|
+
if (typeof value === "function" || typeof value === "symbol") {
|
|
34548
|
+
throw new TypeError(`confirmation: cannot bind a ${typeof value} argument value.`);
|
|
34549
|
+
}
|
|
34550
|
+
if (Array.isArray(value))
|
|
34551
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
34552
|
+
if (value instanceof Date) {
|
|
34553
|
+
return `{"$date":${JSON.stringify(Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString())}}`;
|
|
34554
|
+
}
|
|
34555
|
+
if (ArrayBuffer.isView(value)) {
|
|
34556
|
+
const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
34557
|
+
return `{"$bytes":${JSON.stringify(bytes.toString("base64"))}}`;
|
|
34558
|
+
}
|
|
34559
|
+
if (value instanceof ArrayBuffer)
|
|
34560
|
+
return `{"$bytes":${JSON.stringify(Buffer.from(value).toString("base64"))}}`;
|
|
34561
|
+
if (value instanceof Map) {
|
|
34562
|
+
const entries2 = [...value.entries()].map(([k, v]) => `[${canonicalJson(k)},${canonicalJson(v)}]`).sort();
|
|
34563
|
+
return `{"$map":[${entries2.join(",")}]}`;
|
|
34564
|
+
}
|
|
34565
|
+
if (value instanceof Set) {
|
|
34566
|
+
return `{"$set":[${[...value].map(canonicalJson).sort().join(",")}]}`;
|
|
34567
|
+
}
|
|
34568
|
+
const proto = Object.getPrototypeOf(value);
|
|
34569
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
34570
|
+
throw new TypeError("confirmation: cannot bind a non-plain object argument value (class instance).");
|
|
34571
|
+
}
|
|
34572
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
34573
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(escapeKey(k))}:${canonicalJson(v)}`).join(",")}}`;
|
|
34574
|
+
}
|
|
34575
|
+
function escapeKey(key) {
|
|
34576
|
+
return key.startsWith("$") ? `$${key}` : key;
|
|
34577
|
+
}
|
|
34578
|
+
|
|
34579
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
|
|
34580
|
+
var DEFAULT_REQUEST_KEY = "confirmation";
|
|
34581
|
+
var DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
|
|
34582
|
+
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";
|
|
34583
|
+
var STATE_PREFIX = "mcpu.confirm.v1.";
|
|
34584
|
+
function bindingKey(binding) {
|
|
34585
|
+
if (binding.ttlSeconds !== void 0 && !(Number.isFinite(binding.ttlSeconds) && binding.ttlSeconds > 0)) {
|
|
34586
|
+
throw new RangeError("requireConfirmation: binding.ttlSeconds must be a finite number greater than 0.");
|
|
34587
|
+
}
|
|
34588
|
+
const key = typeof binding.key === "string" ? Buffer.from(binding.key, "utf8") : Buffer.from(binding.key);
|
|
34589
|
+
if (key.length < 32) {
|
|
34590
|
+
throw new RangeError("requireConfirmation: binding.key must be at least 32 bytes.");
|
|
34591
|
+
}
|
|
34592
|
+
return key;
|
|
34593
|
+
}
|
|
34594
|
+
function commitment(action, args) {
|
|
34595
|
+
return createHash("sha256").update(`${action}\0${canonicalJson(args)}`).digest("base64url");
|
|
34596
|
+
}
|
|
34597
|
+
function mintState(key, action, binding) {
|
|
34598
|
+
const exp = Math.floor(Date.now() / 1e3) + (binding.ttlSeconds ?? 600);
|
|
34599
|
+
const body = Buffer.from(JSON.stringify({ c: commitment(action, binding.args), exp })).toString("base64url");
|
|
34600
|
+
const mac3 = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest("base64url");
|
|
34601
|
+
return `${STATE_PREFIX}${body}.${mac3}`;
|
|
34602
|
+
}
|
|
34603
|
+
function verifyState(key, action, binding, state) {
|
|
34604
|
+
if (typeof state !== "string" || !state.startsWith(STATE_PREFIX))
|
|
34605
|
+
return false;
|
|
34606
|
+
const rest = state.slice(STATE_PREFIX.length);
|
|
34607
|
+
const dot = rest.indexOf(".");
|
|
34608
|
+
if (dot < 0)
|
|
34609
|
+
return false;
|
|
34610
|
+
const body = rest.slice(0, dot);
|
|
34611
|
+
const mac3 = Buffer.from(rest.slice(dot + 1), "base64url");
|
|
34612
|
+
const expected = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest();
|
|
34613
|
+
if (mac3.length !== expected.length || !timingSafeEqual(mac3, expected))
|
|
34614
|
+
return false;
|
|
34615
|
+
let payload;
|
|
34616
|
+
try {
|
|
34617
|
+
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
34618
|
+
} catch {
|
|
34619
|
+
return false;
|
|
34620
|
+
}
|
|
34621
|
+
if (typeof payload.exp !== "number" || payload.exp * 1e3 <= Date.now())
|
|
34622
|
+
return false;
|
|
34623
|
+
const want = Buffer.from(commitment(action, binding.args));
|
|
34624
|
+
const got = Buffer.from(typeof payload.c === "string" ? payload.c : "");
|
|
34625
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
34626
|
+
}
|
|
34627
|
+
function echoedState(ctx) {
|
|
34628
|
+
const accessor = ctx.mcpReq.requestState;
|
|
34629
|
+
return typeof accessor === "function" ? accessor() : void 0;
|
|
34630
|
+
}
|
|
34631
|
+
function requireConfirmation(ctx, options) {
|
|
34632
|
+
const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
|
|
34633
|
+
const key = options.binding ? bindingKey(options.binding) : void 0;
|
|
34634
|
+
const confirmationSchema = external_exports.object({
|
|
34635
|
+
confirmed: external_exports.boolean().describe(options.confirmationLabel ?? DEFAULT_CONFIRMATION_LABEL)
|
|
34636
|
+
});
|
|
34637
|
+
const ask = () => {
|
|
34638
|
+
const preview = {
|
|
34639
|
+
action: options.action,
|
|
34640
|
+
...options.details === void 0 ? {} : { details: options.details }
|
|
34641
|
+
};
|
|
34642
|
+
return inputRequired({
|
|
34643
|
+
inputRequests: {
|
|
34644
|
+
[requestKey]: inputRequired.elicit({
|
|
34645
|
+
message: `${options.message}
|
|
34646
|
+
${JSON.stringify(preview, null, 2)}`,
|
|
34647
|
+
requestedSchema: confirmationSchema
|
|
34648
|
+
})
|
|
34649
|
+
},
|
|
34650
|
+
...key && options.binding ? { requestState: mintState(key, options.action, options.binding) } : {}
|
|
34651
|
+
});
|
|
34652
|
+
};
|
|
34653
|
+
const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
|
|
34654
|
+
const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
|
|
34655
|
+
if (response.kind === "missing") {
|
|
34656
|
+
if (callerAcceptsFormElicitation(ctx) === false) {
|
|
34657
|
+
return textResult({
|
|
34658
|
+
confirmed: false,
|
|
34659
|
+
dispatched: false,
|
|
34660
|
+
action: options.action,
|
|
34661
|
+
reason: "confirmation-unsupported",
|
|
34662
|
+
note: options.unsupportedNote ? `${UNSUPPORTED_NOTE}. ${options.unsupportedNote}` : `${UNSUPPORTED_NOTE}.`
|
|
34663
|
+
});
|
|
34664
|
+
}
|
|
34665
|
+
return ask();
|
|
34666
|
+
}
|
|
34667
|
+
if (response.kind === "elicit" && response.action === "accept" && accepted?.confirmed === true) {
|
|
34668
|
+
if (key && options.binding) {
|
|
34669
|
+
const state = echoedState(ctx);
|
|
34670
|
+
if (state === void 0 || state === null) {
|
|
34671
|
+
return errorResult(`Confirmation for ${options.action} was accepted, but the retry carried no requestState, so it cannot be checked against the prompt that was shown. Nothing was done. The likely cause is that the MCP client or host does not round-trip requestState (it must echo the requestState from the input_required result back on the retry); asking again would loop.`);
|
|
34672
|
+
}
|
|
34673
|
+
if (!verifyState(key, options.action, options.binding, state))
|
|
34674
|
+
return ask();
|
|
34675
|
+
}
|
|
34676
|
+
return void 0;
|
|
34677
|
+
}
|
|
34678
|
+
return textResult({
|
|
34679
|
+
confirmed: false,
|
|
34680
|
+
cancelled: true,
|
|
34681
|
+
action: options.action,
|
|
34682
|
+
note: "Nothing was changed because the confirmation was declined, cancelled, or left unchecked."
|
|
34683
|
+
});
|
|
34684
|
+
}
|
|
34685
|
+
|
|
34686
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/confirm-token.js
|
|
34687
|
+
import { createHash as createHash2, createHmac as createHmac2, randomBytes, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
34688
|
+
function createSpentTokenStore() {
|
|
34689
|
+
const spent2 = /* @__PURE__ */ new Map();
|
|
34690
|
+
return {
|
|
34691
|
+
has: (nonce) => spent2.has(nonce),
|
|
34692
|
+
add: (nonce, exp) => {
|
|
34693
|
+
spent2.set(nonce, exp);
|
|
34694
|
+
},
|
|
34695
|
+
prune: (now) => {
|
|
34696
|
+
for (const [nonce, exp] of spent2)
|
|
34697
|
+
if (exp < now)
|
|
34698
|
+
spent2.delete(nonce);
|
|
34699
|
+
},
|
|
34700
|
+
clear: () => spent2.clear(),
|
|
34701
|
+
get size() {
|
|
34702
|
+
return spent2.size;
|
|
34703
|
+
}
|
|
34704
|
+
};
|
|
34705
|
+
}
|
|
34706
|
+
var PROCESS_SPENT = createSpentTokenStore();
|
|
34707
|
+
var PREFIX = "mcpu.token.v1.";
|
|
34708
|
+
var DEFAULT_TTL_SECONDS = 600;
|
|
34709
|
+
function tokenKey(key) {
|
|
34710
|
+
const bytes = typeof key === "string" ? Buffer.from(key, "utf8") : Buffer.from(key);
|
|
34711
|
+
if (bytes.length < 32)
|
|
34712
|
+
throw new RangeError("confirm token: key must be at least 32 bytes.");
|
|
34713
|
+
return bytes;
|
|
34714
|
+
}
|
|
34715
|
+
function ttlOf(ttlSeconds) {
|
|
34716
|
+
if (ttlSeconds === void 0)
|
|
34717
|
+
return DEFAULT_TTL_SECONDS;
|
|
34718
|
+
if (!(Number.isFinite(ttlSeconds) && ttlSeconds > 0)) {
|
|
34719
|
+
throw new RangeError("confirm token: ttlSeconds must be a finite number greater than 0.");
|
|
34720
|
+
}
|
|
34721
|
+
return ttlSeconds;
|
|
34722
|
+
}
|
|
34723
|
+
function sign(key, body) {
|
|
34724
|
+
return createHmac2("sha256", key).update(`${PREFIX}${body}`).digest();
|
|
34725
|
+
}
|
|
34726
|
+
function hashConfirmPayload(payload) {
|
|
34727
|
+
return createHash2("sha256").update(canonicalJson(payload)).digest("base64url");
|
|
34728
|
+
}
|
|
34729
|
+
function issueConfirmToken(key, binding, options = {}) {
|
|
34730
|
+
const k = tokenKey(key);
|
|
34731
|
+
const now = options.now ?? Date.now();
|
|
34732
|
+
const exp = now + ttlOf(options.ttlSeconds) * 1e3;
|
|
34733
|
+
const claims = {
|
|
34734
|
+
t: binding.tool,
|
|
34735
|
+
...binding.account === void 0 ? {} : { a: binding.account },
|
|
34736
|
+
g: binding.target,
|
|
34737
|
+
...binding.revision === void 0 ? {} : { r: binding.revision },
|
|
34738
|
+
h: binding.payloadHash,
|
|
34739
|
+
exp,
|
|
34740
|
+
n: randomBytes(16).toString("base64url")
|
|
34741
|
+
};
|
|
34742
|
+
const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
|
|
34743
|
+
return { token: `${PREFIX}${body}.${sign(k, body).toString("base64url")}`, expiresAt: new Date(exp).toISOString() };
|
|
34744
|
+
}
|
|
34745
|
+
function parseClaims(body) {
|
|
34746
|
+
try {
|
|
34747
|
+
const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
34748
|
+
return claims && typeof claims === "object" ? claims : void 0;
|
|
34749
|
+
} catch {
|
|
34750
|
+
return void 0;
|
|
34751
|
+
}
|
|
34752
|
+
}
|
|
34753
|
+
function verifyConfirmToken(key, token, binding, options = {}) {
|
|
34754
|
+
const k = tokenKey(key);
|
|
34755
|
+
const now = options.now ?? Date.now();
|
|
34756
|
+
const spent2 = options.spent ?? PROCESS_SPENT;
|
|
34757
|
+
spent2.prune(now);
|
|
34758
|
+
if (!token.startsWith(PREFIX))
|
|
34759
|
+
return { ok: false, error: "TOKEN_INVALID" };
|
|
34760
|
+
const rest = token.slice(PREFIX.length);
|
|
34761
|
+
const dot = rest.indexOf(".");
|
|
34762
|
+
const body = dot < 0 ? "" : rest.slice(0, dot);
|
|
34763
|
+
const mac3 = Buffer.from(dot < 0 ? "" : rest.slice(dot + 1), "base64url");
|
|
34764
|
+
const expected = sign(k, body);
|
|
34765
|
+
if (!body || mac3.length !== expected.length || !timingSafeEqual2(mac3, expected))
|
|
34766
|
+
return { ok: false, error: "TOKEN_INVALID" };
|
|
34767
|
+
const claims = parseClaims(body);
|
|
34768
|
+
if (!claims)
|
|
34769
|
+
return { ok: false, error: "TOKEN_INVALID" };
|
|
34770
|
+
if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
|
|
34771
|
+
return { ok: false, error: "TOKEN_INVALID" };
|
|
34772
|
+
}
|
|
34773
|
+
if (spent2.has(claims.n))
|
|
34774
|
+
return { ok: false, error: "TOKEN_REUSED" };
|
|
34775
|
+
if (now > claims.exp)
|
|
34776
|
+
return { ok: false, error: "TOKEN_EXPIRED" };
|
|
34777
|
+
if (claims.r !== binding.revision)
|
|
34778
|
+
return { ok: false, error: "DRAFT_CHANGED", reason: "revision-changed" };
|
|
34779
|
+
if (claims.h !== binding.payloadHash)
|
|
34780
|
+
return { ok: false, error: "DRAFT_CHANGED", reason: "payload-changed" };
|
|
34781
|
+
spent2.add(claims.n, claims.exp);
|
|
34782
|
+
return { ok: true };
|
|
34783
|
+
}
|
|
34784
|
+
var CONFIRM_TOKEN_INSTRUCTION = "Show this preview to the user verbatim and proceed only after they explicitly approve in chat. Then call again with confirmToken.";
|
|
34785
|
+
var confirmTokenParam = external_exports.string().optional().describe(`ONLY for the two-step confirmation fallback (a client without MCP elicitation). The confirmToken from this same tool's phase-1 "confirmation-required" response, passed back ONLY after the user has seen that preview and explicitly approved it in chat \u2014 never on the first call, never invented, never reused. Call again with the same arguments. Ignored when the client supports elicitation.`);
|
|
34786
|
+
var TOKEN_ERROR_NOTE = {
|
|
34787
|
+
TOKEN_EXPIRED: "Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, and ask the user to approve it again.",
|
|
34788
|
+
TOKEN_REUSED: "Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.",
|
|
34789
|
+
TOKEN_INVALID: "Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval."
|
|
34790
|
+
};
|
|
34791
|
+
var CHANGED_NOTE = {
|
|
34792
|
+
"revision-changed": "Nothing was sent or changed: the target was edited since the user approved it (its version rotated), so what would happen is not what they saw.",
|
|
34793
|
+
"payload-changed": "Nothing was sent or changed: what would happen no longer matches what the user approved."
|
|
34794
|
+
};
|
|
34795
|
+
function isToolResult(value) {
|
|
34796
|
+
return Array.isArray(value.content);
|
|
34797
|
+
}
|
|
34798
|
+
function rejection2(data) {
|
|
34799
|
+
return { ...textResult({ status: "confirmation-rejected", confirmed: false, dispatched: false, ...data }), isError: true };
|
|
34800
|
+
}
|
|
34801
|
+
async function tokenConfirmation(action, fb) {
|
|
34802
|
+
const subject = await fb.subject();
|
|
34803
|
+
if (isToolResult(subject))
|
|
34804
|
+
return subject;
|
|
34805
|
+
const binding = {
|
|
34806
|
+
tool: fb.tool,
|
|
34807
|
+
...fb.account === void 0 ? {} : { account: fb.account },
|
|
34808
|
+
target: subject.target,
|
|
34809
|
+
...subject.revision === void 0 ? {} : { revision: subject.revision },
|
|
34810
|
+
payloadHash: hashConfirmPayload(subject.payload)
|
|
34811
|
+
};
|
|
34812
|
+
const phaseOne = () => {
|
|
34813
|
+
const { token, expiresAt } = issueConfirmToken(fb.key, binding, { ttlSeconds: fb.ttlSeconds });
|
|
34814
|
+
return {
|
|
34815
|
+
action,
|
|
34816
|
+
preview: subject.preview,
|
|
34817
|
+
confirmToken: token,
|
|
34818
|
+
expiresAt,
|
|
34819
|
+
ttlSeconds: ttlOf(fb.ttlSeconds),
|
|
34820
|
+
instruction: fb.instruction ?? CONFIRM_TOKEN_INSTRUCTION
|
|
34821
|
+
};
|
|
34822
|
+
};
|
|
34823
|
+
if (!fb.confirmToken) {
|
|
34824
|
+
return textResult({ status: "confirmation-required", confirmed: false, dispatched: false, ...phaseOne() });
|
|
34825
|
+
}
|
|
34826
|
+
const verdict = verifyConfirmToken(fb.key, fb.confirmToken, binding, { spent: fb.spent });
|
|
34827
|
+
if (verdict.ok)
|
|
34828
|
+
return void 0;
|
|
34829
|
+
if (verdict.error === "DRAFT_CHANGED") {
|
|
34830
|
+
return rejection2({
|
|
34831
|
+
error: "DRAFT_CHANGED",
|
|
34832
|
+
reason: verdict.reason,
|
|
34833
|
+
note: `${CHANGED_NOTE[verdict.reason]} The current preview and a fresh confirmToken are below.`,
|
|
34834
|
+
...phaseOne()
|
|
34835
|
+
});
|
|
34836
|
+
}
|
|
34837
|
+
return rejection2({ error: verdict.error, action, note: TOKEN_ERROR_NOTE[verdict.error] });
|
|
34838
|
+
}
|
|
34839
|
+
async function requireConfirmationWithFallback(ctx, options) {
|
|
34840
|
+
const { tokenFallback, ...confirmation } = options;
|
|
34841
|
+
if (tokenFallback && callerAcceptsFormElicitation(ctx) === false) {
|
|
34842
|
+
return tokenConfirmation(options.action, tokenFallback);
|
|
34843
|
+
}
|
|
34844
|
+
return requireConfirmation(ctx, confirmation);
|
|
34845
|
+
}
|
|
34846
|
+
|
|
34847
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
|
|
34848
|
+
import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
|
|
34849
|
+
|
|
34850
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
34851
|
+
import { homedir } from "node:os";
|
|
34852
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
34853
|
+
var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
|
|
34854
|
+
function readEnvVar(key, opts = {}) {
|
|
34855
|
+
const env = opts.env ?? process.env;
|
|
34856
|
+
const raw = env[key];
|
|
34857
|
+
if (typeof raw === "string") {
|
|
34858
|
+
const trimmed = raw.trim();
|
|
34859
|
+
if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
|
|
34860
|
+
return trimmed;
|
|
34861
|
+
}
|
|
34862
|
+
}
|
|
34863
|
+
return opts.default;
|
|
34864
|
+
}
|
|
34865
|
+
var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
34866
|
+
var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
34867
|
+
function parseBoolEnv(key, opts = {}) {
|
|
34868
|
+
const fallback = opts.default ?? false;
|
|
34869
|
+
const raw = readEnvVar(key, { env: opts.env });
|
|
34870
|
+
if (raw === void 0)
|
|
34871
|
+
return fallback;
|
|
34872
|
+
const token = raw.toLowerCase();
|
|
34873
|
+
if (TRUE_TOKENS.has(token))
|
|
34874
|
+
return true;
|
|
34875
|
+
if (FALSE_TOKENS.has(token))
|
|
34876
|
+
return false;
|
|
34877
|
+
return fallback;
|
|
34878
|
+
}
|
|
34879
|
+
function expandPath(p) {
|
|
34880
|
+
let expanded = p;
|
|
34881
|
+
if (p === "~") {
|
|
34882
|
+
expanded = homedir();
|
|
34883
|
+
} else if (p.startsWith("~/")) {
|
|
34884
|
+
expanded = join(homedir(), p.slice(2));
|
|
34885
|
+
}
|
|
34886
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
34887
|
+
}
|
|
34888
|
+
|
|
34889
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
|
|
34890
|
+
var MODES = /* @__PURE__ */ new Set(["ask-user", "auto", "refuse"]);
|
|
34891
|
+
var DEFAULT_TTL_SECONDS2 = 600;
|
|
34892
|
+
var warned = /* @__PURE__ */ new Set();
|
|
34893
|
+
function readConfirmMode(env = process.env) {
|
|
34894
|
+
const raw = readEnvVar("MCP_CONFIRM_MODE", { env })?.trim().toLowerCase();
|
|
34895
|
+
if (!raw)
|
|
34896
|
+
return "ask-user";
|
|
34897
|
+
if (MODES.has(raw))
|
|
34898
|
+
return raw;
|
|
34899
|
+
if (!warned.has(raw)) {
|
|
34900
|
+
warned.add(raw);
|
|
34901
|
+
process.stderr.write(`MCP_CONFIRM_MODE="${raw}" is not one of ask-user, auto, refuse; treating it as refuse.
|
|
34902
|
+
`);
|
|
34903
|
+
}
|
|
34904
|
+
return "refuse";
|
|
34905
|
+
}
|
|
34906
|
+
function confirmTtlFromEnv(env = process.env) {
|
|
34907
|
+
const raw = readEnvVar("MCP_CONFIRM_TTL_SECONDS", { env })?.trim();
|
|
34908
|
+
return raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : DEFAULT_TTL_SECONDS2;
|
|
34909
|
+
}
|
|
34910
|
+
var processKey;
|
|
34911
|
+
function confirmKeyFromEnv(env = process.env) {
|
|
34912
|
+
const secret = readEnvVar("MCP_CONFIRM_SECRET", { env });
|
|
34913
|
+
if (secret)
|
|
34914
|
+
return createHash3("sha256").update(secret, "utf8").digest();
|
|
34915
|
+
processKey ??= randomBytes2(32);
|
|
34916
|
+
return processKey;
|
|
34917
|
+
}
|
|
34918
|
+
var CONFIRM_TOKEN_AUTO_INSTRUCTION = "Nothing has been done yet. Review this preview; if it is what was intended, call again with the same arguments plus confirmToken. (This server runs with MCP_CONFIRM_MODE=auto, so the user's approval in chat is not required.)";
|
|
34919
|
+
var REFUSE_HINT = "Set MCP_CONFIRM_MODE=ask-user on the server to allow two-step confirmation instead.";
|
|
34920
|
+
function confirmationFromEnv(options) {
|
|
34921
|
+
const { tool, account, confirmToken, subject, instruction, spent: spent2, env = process.env, ...confirmation } = options;
|
|
34922
|
+
const mode = readConfirmMode(env);
|
|
34923
|
+
if (mode === "refuse") {
|
|
34924
|
+
return {
|
|
34925
|
+
...confirmation,
|
|
34926
|
+
unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${REFUSE_HINT}` : REFUSE_HINT
|
|
34927
|
+
};
|
|
34928
|
+
}
|
|
34929
|
+
return {
|
|
34930
|
+
...confirmation,
|
|
34931
|
+
tokenFallback: {
|
|
34932
|
+
key: confirmKeyFromEnv(env),
|
|
34933
|
+
tool,
|
|
34934
|
+
...account === void 0 ? {} : { account },
|
|
34935
|
+
...confirmToken === void 0 ? {} : { confirmToken },
|
|
34936
|
+
subject,
|
|
34937
|
+
ttlSeconds: confirmTtlFromEnv(env),
|
|
34938
|
+
instruction: mode === "auto" ? CONFIRM_TOKEN_AUTO_INSTRUCTION : instruction ?? CONFIRM_TOKEN_INSTRUCTION,
|
|
34939
|
+
...spent2 === void 0 ? {} : { spent: spent2 }
|
|
34940
|
+
}
|
|
34941
|
+
};
|
|
34942
|
+
}
|
|
34943
|
+
|
|
34471
34944
|
// ../../node_modules/@chrischall/mcp-utils/dist/server/index.js
|
|
34472
34945
|
var SERVER_PROTOCOL_VERSIONS = Object.freeze([
|
|
34473
34946
|
"2026-07-28",
|
|
@@ -34643,45 +35116,6 @@ function runMcp(opts) {
|
|
|
34643
35116
|
return handle;
|
|
34644
35117
|
}
|
|
34645
35118
|
|
|
34646
|
-
// ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
34647
|
-
import { homedir } from "node:os";
|
|
34648
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
34649
|
-
var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
|
|
34650
|
-
function readEnvVar(key, opts = {}) {
|
|
34651
|
-
const env = opts.env ?? process.env;
|
|
34652
|
-
const raw = env[key];
|
|
34653
|
-
if (typeof raw === "string") {
|
|
34654
|
-
const trimmed = raw.trim();
|
|
34655
|
-
if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
|
|
34656
|
-
return trimmed;
|
|
34657
|
-
}
|
|
34658
|
-
}
|
|
34659
|
-
return opts.default;
|
|
34660
|
-
}
|
|
34661
|
-
var TRUE_TOKENS = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
|
|
34662
|
-
var FALSE_TOKENS = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
34663
|
-
function parseBoolEnv(key, opts = {}) {
|
|
34664
|
-
const fallback = opts.default ?? false;
|
|
34665
|
-
const raw = readEnvVar(key, { env: opts.env });
|
|
34666
|
-
if (raw === void 0)
|
|
34667
|
-
return fallback;
|
|
34668
|
-
const token = raw.toLowerCase();
|
|
34669
|
-
if (TRUE_TOKENS.has(token))
|
|
34670
|
-
return true;
|
|
34671
|
-
if (FALSE_TOKENS.has(token))
|
|
34672
|
-
return false;
|
|
34673
|
-
return fallback;
|
|
34674
|
-
}
|
|
34675
|
-
function expandPath(p) {
|
|
34676
|
-
let expanded = p;
|
|
34677
|
-
if (p === "~") {
|
|
34678
|
-
expanded = homedir();
|
|
34679
|
-
} else if (p.startsWith("~/")) {
|
|
34680
|
-
expanded = join(homedir(), p.slice(2));
|
|
34681
|
-
}
|
|
34682
|
-
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
34683
|
-
}
|
|
34684
|
-
|
|
34685
35119
|
// ../../node_modules/@chrischall/mcp-utils/dist/fs/confine.js
|
|
34686
35120
|
import { existsSync, realpathSync } from "node:fs";
|
|
34687
35121
|
import { dirname, isAbsolute as isAbsolute2, relative, resolve as resolve2, sep } from "node:path";
|
|
@@ -34763,9 +35197,9 @@ function offsetAt(instant, tz) {
|
|
|
34763
35197
|
const w = wallPartsIn(instant, tz);
|
|
34764
35198
|
const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
|
|
34765
35199
|
const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
|
|
34766
|
-
const
|
|
35200
|
+
const sign2 = minutes < 0 ? "-" : "+";
|
|
34767
35201
|
const abs = Math.abs(minutes);
|
|
34768
|
-
return `${
|
|
35202
|
+
return `${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
34769
35203
|
}
|
|
34770
35204
|
function wallPartsIn(instant, tz) {
|
|
34771
35205
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
@@ -35470,6 +35904,7 @@ async function diagnose(err) {
|
|
|
35470
35904
|
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
35471
35905
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
35472
35906
|
const hint = isInvalidGrant ? INVALID_GRANT_HINT : isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
|
|
35907
|
+
if (!isAuthError && !isInvalidGrant) return errorResult(`${errText}${hint}`);
|
|
35473
35908
|
try {
|
|
35474
35909
|
const accounts = formatAccountList(await run(["auth", "list"]));
|
|
35475
35910
|
return errorResult(`${errText}
|
|
@@ -35558,10 +35993,44 @@ function formatAuthHealth(raw, now) {
|
|
|
35558
35993
|
return accounts.map((a) => formatOneAccountHealth(a, now)).join("\n\n");
|
|
35559
35994
|
}
|
|
35560
35995
|
|
|
35996
|
+
// ../gogcli-mcp/src/send-confirm-token.ts
|
|
35997
|
+
var spent = createSpentTokenStore();
|
|
35998
|
+
function confirmSpentStore() {
|
|
35999
|
+
return spent;
|
|
36000
|
+
}
|
|
36001
|
+
|
|
35561
36002
|
// ../gogcli-mcp/src/dispatch-confirmation.ts
|
|
35562
|
-
var
|
|
35563
|
-
|
|
35564
|
-
)
|
|
36003
|
+
var CONFIRM_ACTION_INSTRUCTION = CONFIRM_TOKEN_INSTRUCTION;
|
|
36004
|
+
var CONFIRM_FALLBACK_DESCRIPTION = ' If the client cannot show that prompt (no MCP elicitation, e.g. claude.ai), a two-step flow applies instead: call WITHOUT confirmToken and nothing is sent or changed \u2014 the result has status "confirmation-required", the full preview and a confirmToken. Follow its instruction (by default: show the preview to the user verbatim and only after they explicitly approve it in chat, call again with the SAME arguments plus confirmToken). The tool re-reads what it would act on and refuses (DRAFT_CHANGED, with a fresh preview and token) if it changed; TOKEN_EXPIRED / TOKEN_REUSED / TOKEN_INVALID also do nothing. A server set to MCP_CONFIRM_MODE=refuse refuses instead.';
|
|
36005
|
+
function refusedInRun(what, via, does, instead) {
|
|
36006
|
+
return `${what} ${does} and is not available through ${via}. ${instead}`;
|
|
36007
|
+
}
|
|
36008
|
+
function gatedElsewhere(what, via, does, tool) {
|
|
36009
|
+
return refusedInRun(what, via, does, `Use ${tool}, which asks the user to confirm.`);
|
|
36010
|
+
}
|
|
36011
|
+
function hasCommandWord(args, words) {
|
|
36012
|
+
return args.find((a) => words.has(a.toLowerCase()));
|
|
36013
|
+
}
|
|
36014
|
+
async function requireDispatchConfirmation(ctx, options) {
|
|
36015
|
+
const { action, fallback } = options;
|
|
36016
|
+
const confirmation = {
|
|
36017
|
+
action,
|
|
36018
|
+
message: options.message,
|
|
36019
|
+
details: options.details,
|
|
36020
|
+
confirmationLabel: options.confirmationLabel,
|
|
36021
|
+
...options.unsupportedNote ? { unsupportedNote: options.unsupportedNote } : {}
|
|
36022
|
+
};
|
|
36023
|
+
if (!fallback) return requireConfirmationWithFallback(ctx, confirmation);
|
|
36024
|
+
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
36025
|
+
...confirmation,
|
|
36026
|
+
tool: fallback.tool,
|
|
36027
|
+
account: fallback.account ?? readEnvVar("GOG_ACCOUNT") ?? "",
|
|
36028
|
+
confirmToken: fallback.confirmToken,
|
|
36029
|
+
subject: fallback.subject,
|
|
36030
|
+
instruction: fallback.instruction ?? CONFIRM_ACTION_INSTRUCTION,
|
|
36031
|
+
spent: confirmSpentStore()
|
|
36032
|
+
}));
|
|
36033
|
+
}
|
|
35565
36034
|
|
|
35566
36035
|
// ../gogcli-mcp/src/tools/auth.ts
|
|
35567
36036
|
var AUTH_RUN_SUBCOMMANDS = ["list", "status", "services", "remove", "alias"];
|
|
@@ -35698,6 +36167,27 @@ function authToolsFor(defaultServices) {
|
|
|
35698
36167
|
}
|
|
35699
36168
|
|
|
35700
36169
|
// ../gogcli-mcp/src/tools/calendar.ts
|
|
36170
|
+
var ASKS = (tool) => `Use ${tool}, which asks the user to confirm.`;
|
|
36171
|
+
var CALENDAR_REFUSED = {
|
|
36172
|
+
create: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
|
|
36173
|
+
add: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
|
|
36174
|
+
new: { does: "can change what guests see", instead: ASKS("gog_calendar_create") },
|
|
36175
|
+
update: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
|
|
36176
|
+
edit: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
|
|
36177
|
+
set: { does: "can change what guests see", instead: ASKS("gog_calendar_update") },
|
|
36178
|
+
respond: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
|
|
36179
|
+
rsvp: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
|
|
36180
|
+
reply: { does: "can change what guests see", instead: ASKS("gog_calendar_respond") },
|
|
36181
|
+
move: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS("gog_calendar_move") },
|
|
36182
|
+
transfer: { does: "moves an event off guests' calendars (and can email them)", instead: ASKS("gog_calendar_move") },
|
|
36183
|
+
delete: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
|
|
36184
|
+
del: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
|
|
36185
|
+
rm: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
|
|
36186
|
+
remove: { does: "removes an event from every guest's calendar (the whole series by default)", instead: ASKS("gog_calendar_delete") },
|
|
36187
|
+
"delete-calendar": { does: "deletes a calendar and every event on it", instead: ASKS("gog_calendar_delete_calendar") },
|
|
36188
|
+
"out-of-office": { does: "auto-declines invitations and notifies their organizers", instead: ASKS("gog_calendar_out_of_office") },
|
|
36189
|
+
ooo: { does: "auto-declines invitations and notifies their organizers", instead: ASKS("gog_calendar_out_of_office") }
|
|
36190
|
+
};
|
|
35701
36191
|
var reminderParams = {
|
|
35702
36192
|
reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
|
|
35703
36193
|
`Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d (max 40320 minutes = 4 weeks). Google allows at most 5. These REPLACE the event's reminders \u2014 on update, pass an EMPTY array to drop custom reminders and go back to the calendar's defaults. Cannot be combined with noReminders.`
|
|
@@ -35799,6 +36289,13 @@ function countNonEmptyCells(getOutput) {
|
|
|
35799
36289
|
}
|
|
35800
36290
|
|
|
35801
36291
|
// ../gogcli-mcp/src/tools/sheets.ts
|
|
36292
|
+
var DATASOURCE_ALIASES = /* @__PURE__ */ new Set(["datasource", "data-source", "data-sources", "connected-sheets"]);
|
|
36293
|
+
var DATASOURCE_BILLED = /* @__PURE__ */ new Set(["add", "update", "refresh"]);
|
|
36294
|
+
function vetSheetsRun(subcommand, args) {
|
|
36295
|
+
if (!DATASOURCE_ALIASES.has(subcommand.toLowerCase())) return void 0;
|
|
36296
|
+
const word = hasCommandWord(args, DATASOURCE_BILLED)?.toLowerCase();
|
|
36297
|
+
return word ? gatedElsewhere(`gog sheets datasource ${word}`, "gog_sheets_run", "runs a billed BigQuery job", `gog_sheets_datasource_${word}`) : void 0;
|
|
36298
|
+
}
|
|
35802
36299
|
var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
|
|
35803
36300
|
var dryRunParam = external_exports.boolean().optional().describe(
|
|
35804
36301
|
"Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
|
|
@@ -35914,11 +36411,11 @@ function registerSheetsTools(server) {
|
|
|
35914
36411
|
}, async ({ spreadsheetId, find, replace, account }) => {
|
|
35915
36412
|
return runOrDiagnose(["sheets", "find-replace", pos(spreadsheetId), pos(find), pos(replace)], { account });
|
|
35916
36413
|
});
|
|
35917
|
-
registerRunTool(server, { service: "sheets", examples: '"freeze", "add-tab", "rename-tab"' });
|
|
36414
|
+
registerRunTool(server, { service: "sheets", examples: '"freeze", "add-tab", "rename-tab"', vet: vetSheetsRun });
|
|
35918
36415
|
}
|
|
35919
36416
|
|
|
35920
36417
|
// ../gogcli-mcp/src/server.ts
|
|
35921
|
-
var VERSION = true ? "4.
|
|
36418
|
+
var VERSION = true ? "4.5.1" : "0.0.0";
|
|
35922
36419
|
|
|
35923
36420
|
// ../gogcli-mcp/src/bootstrap-auth.ts
|
|
35924
36421
|
import { join as join5 } from "node:path";
|
|
@@ -35955,12 +36452,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
|
|
|
35955
36452
|
const [clientId, clientSecret, refreshToken, account] = values;
|
|
35956
36453
|
const { spawner } = options;
|
|
35957
36454
|
try {
|
|
35958
|
-
const { createHash } = await import("node:crypto");
|
|
36455
|
+
const { createHash: createHash4 } = await import("node:crypto");
|
|
35959
36456
|
const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
|
|
35960
36457
|
const home = options.home ?? (await import("node:os")).homedir();
|
|
35961
36458
|
const dir = join5(home, ".gogcli-mcp");
|
|
35962
36459
|
const marker = join5(dir, AUTH_BOOTSTRAP_MARKER);
|
|
35963
|
-
const fingerprint =
|
|
36460
|
+
const fingerprint = createHash4("sha256").update(values.join("\0")).digest("hex");
|
|
35964
36461
|
const previous = await readFile(marker, "utf8").catch(() => void 0);
|
|
35965
36462
|
if (previous === fingerprint && await accountListed(account, spawner)) return "present";
|
|
35966
36463
|
await run(
|
|
@@ -35997,6 +36494,32 @@ function resultText2(result) {
|
|
|
35997
36494
|
const first = result.content[0];
|
|
35998
36495
|
return first?.type === "text" ? first.text : void 0;
|
|
35999
36496
|
}
|
|
36497
|
+
function spreadsheetTitle(raw) {
|
|
36498
|
+
try {
|
|
36499
|
+
const parsed = JSON.parse(String(raw));
|
|
36500
|
+
const title = parsed?.spreadsheet?.properties?.title ?? parsed?.properties?.title;
|
|
36501
|
+
return typeof title === "string" ? title : void 0;
|
|
36502
|
+
} catch {
|
|
36503
|
+
return void 0;
|
|
36504
|
+
}
|
|
36505
|
+
}
|
|
36506
|
+
function dataSourceSnapshot(raw) {
|
|
36507
|
+
let bq;
|
|
36508
|
+
try {
|
|
36509
|
+
const parsed = JSON.parse(String(raw));
|
|
36510
|
+
bq = (parsed?.dataSource ?? parsed)?.spec?.bigQuery;
|
|
36511
|
+
} catch {
|
|
36512
|
+
bq = void 0;
|
|
36513
|
+
}
|
|
36514
|
+
const str = (v) => typeof v === "string" ? v : void 0;
|
|
36515
|
+
const t = bq?.tableSpec;
|
|
36516
|
+
const table = [t?.tableProjectId, t?.datasetId, t?.tableId].map(str).filter(Boolean).join(".");
|
|
36517
|
+
return {
|
|
36518
|
+
...str(bq?.projectId) ? { billingProject: str(bq?.projectId) } : {},
|
|
36519
|
+
...str(bq?.querySpec?.rawQuery) ? { query: str(bq?.querySpec?.rawQuery) } : {},
|
|
36520
|
+
...table ? { table } : {}
|
|
36521
|
+
};
|
|
36522
|
+
}
|
|
36000
36523
|
function hexToRgb(hex3) {
|
|
36001
36524
|
let h = hex3.trim().replace(/^#/, "");
|
|
36002
36525
|
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
@@ -36035,6 +36558,25 @@ async function checkDateFormatTarget(spreadsheetId, range, account) {
|
|
|
36035
36558
|
if (!sawSmallInt) return null;
|
|
36036
36559
|
return "Warning: applying DATE/DATE_TIME format to cells holding small integers (< 10000) will render them as dates near 1899-12-30 because Sheets interprets numeric values as day-serials from that epoch. If those integers are ordinals (1, 2, 3, ...) and not day offsets, this is almost certainly not what you want. Pass force:true to suppress this warning, or convert the cells to real dates / strings first.";
|
|
36037
36560
|
}
|
|
36561
|
+
async function confirmBilledExecution(ctx, o) {
|
|
36562
|
+
const got = await runOrDiagnose(["sheets", "metadata", pos(o.spreadsheetId), "--select=properties.title"], { account: o.account });
|
|
36563
|
+
if (got.isError) return got;
|
|
36564
|
+
const title = spreadsheetTitle(resultText2(got));
|
|
36565
|
+
const view = { spreadsheet: { id: o.spreadsheetId, ...title !== void 0 ? { title } : {} }, ...o.view };
|
|
36566
|
+
return requireDispatchConfirmation(ctx, {
|
|
36567
|
+
action: o.action,
|
|
36568
|
+
message: "Review and confirm starting a BigQuery execution \u2014 it is billed to the billing project:",
|
|
36569
|
+
confirmationLabel: "Confirm that this billed BigQuery query should run now.",
|
|
36570
|
+
details: view,
|
|
36571
|
+
unsupportedNote: "Ask the user to run it from Google Sheets (Data > Data connectors).",
|
|
36572
|
+
fallback: {
|
|
36573
|
+
tool: o.tool,
|
|
36574
|
+
account: o.account,
|
|
36575
|
+
confirmToken: o.confirmToken,
|
|
36576
|
+
subject: () => ({ target: o.target, payload: view, preview: view })
|
|
36577
|
+
}
|
|
36578
|
+
});
|
|
36579
|
+
}
|
|
36038
36580
|
function registerExtraSheetsTools(server) {
|
|
36039
36581
|
server.registerTool("gog_sheets_list_tabs", {
|
|
36040
36582
|
description: "List tabs (sheets) in a spreadsheet with their titles, sheetIds, and indices. A friendlier view than gog_sheets_metadata when you only need the tab list \u2014 useful for restructuring a workbook over a long agent session without losing track of names.",
|
|
@@ -36955,7 +37497,7 @@ ${errorText(err)}`
|
|
|
36955
37497
|
if (render) args.push(`--render=${render}`);
|
|
36956
37498
|
return runOrDiagnose(args, { account });
|
|
36957
37499
|
});
|
|
36958
|
-
const bigQueryChargeNote = " COSTS MONEY AND RUNS ASYNCHRONOUSLY: this starts a BigQuery execution billed to the billing project, and returns once the execution has been REQUESTED \u2014 not once it has finished. Poll gog_sheets_datasource_describe until its dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current.";
|
|
37500
|
+
const bigQueryChargeNote = " COSTS MONEY AND RUNS ASYNCHRONOUSLY: this starts a BigQuery execution billed to the billing project, and returns once the execution has been REQUESTED \u2014 not once it has finished. Poll gog_sheets_datasource_describe until its dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current. Because it bills, it first asks the MCP host to show the user a confirmation prompt naming the spreadsheet, the query or table and the billing project; nothing runs unless they accept." + CONFIRM_FALLBACK_DESCRIPTION;
|
|
36959
37501
|
server.registerTool("gog_sheets_datasource_add", {
|
|
36960
37502
|
description: "Add a BigQuery Connected Sheets data source to a spreadsheet, creating its linked DATA_SOURCE sheet. Point it at EITHER a custom SQL query (query) OR an existing native table (dataset + table) \u2014 never both. billingProject is the BigQuery project charged for the query; tableProject only names which project OWNS the table, and defaults to the billing project. Returns the new dataSourceId, which every other datasource tool takes." + bigQueryChargeNote + bigQueryScopeNote,
|
|
36961
37503
|
annotations: { destructiveHint: false },
|
|
@@ -36966,9 +37508,10 @@ ${errorText(err)}`
|
|
|
36966
37508
|
dataset: external_exports.string().optional().describe("BigQuery dataset ID of an existing table. Requires table; mutually exclusive with query."),
|
|
36967
37509
|
table: external_exports.string().optional().describe("BigQuery table ID. Requires dataset; mutually exclusive with query."),
|
|
36968
37510
|
tableProject: external_exports.string().optional().describe("BigQuery project that owns the table (default: the billing project). Only valid with dataset/table."),
|
|
36969
|
-
account: accountParam
|
|
37511
|
+
account: accountParam,
|
|
37512
|
+
confirmToken: confirmTokenParam
|
|
36970
37513
|
})
|
|
36971
|
-
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
37514
|
+
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
36972
37515
|
const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
|
|
36973
37516
|
if (query !== void 0 && tableFlags) {
|
|
36974
37517
|
throw new Error("query and the table fields (dataset, table, tableProject) are mutually exclusive \u2014 a data source is backed by one or the other.");
|
|
@@ -36981,6 +37524,17 @@ ${errorText(err)}`
|
|
|
36981
37524
|
if (tableProject) args.push(`--table-project=${tableProject}`);
|
|
36982
37525
|
if (dataset) args.push(`--dataset=${dataset}`);
|
|
36983
37526
|
if (table) args.push(`--table=${table}`);
|
|
37527
|
+
const source = query !== void 0 ? { query } : { table: [tableProject ?? billingProject, dataset, table].join(".") };
|
|
37528
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
37529
|
+
tool: "gog_sheets_datasource_add",
|
|
37530
|
+
action: "sheets.datasource-add",
|
|
37531
|
+
account,
|
|
37532
|
+
confirmToken,
|
|
37533
|
+
spreadsheetId,
|
|
37534
|
+
target: spreadsheetId,
|
|
37535
|
+
view: { billingProject, ...source }
|
|
37536
|
+
});
|
|
37537
|
+
if (confirmation) return confirmation;
|
|
36984
37538
|
return runOrDiagnose(args, { account });
|
|
36985
37539
|
});
|
|
36986
37540
|
server.registerTool("gog_sheets_datasource_update", {
|
|
@@ -36994,9 +37548,10 @@ ${errorText(err)}`
|
|
|
36994
37548
|
dataset: external_exports.string().optional().describe("Replacement dataset ID. Only valid on a table-backed source."),
|
|
36995
37549
|
table: external_exports.string().optional().describe("Replacement table ID. Only valid on a table-backed source."),
|
|
36996
37550
|
tableProject: external_exports.string().optional().describe("Replacement project owning the table. Only valid on a table-backed source."),
|
|
36997
|
-
account: accountParam
|
|
37551
|
+
account: accountParam,
|
|
37552
|
+
confirmToken: confirmTokenParam
|
|
36998
37553
|
})
|
|
36999
|
-
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
37554
|
+
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
37000
37555
|
const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
|
|
37001
37556
|
if (query !== void 0 && tableFlags) {
|
|
37002
37557
|
throw new Error("query and the table fields (dataset, table, tableProject) are mutually exclusive \u2014 a data source is backed by one or the other.");
|
|
@@ -37010,6 +37565,17 @@ ${errorText(err)}`
|
|
|
37010
37565
|
if (tableProject !== void 0) args.push(`--table-project=${tableProject}`);
|
|
37011
37566
|
if (dataset !== void 0) args.push(`--dataset=${dataset}`);
|
|
37012
37567
|
if (table !== void 0) args.push(`--table=${table}`);
|
|
37568
|
+
const changes = Object.fromEntries(Object.entries({ billingProject, query, tableProject, dataset, table }).filter(([, v]) => v !== void 0));
|
|
37569
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
37570
|
+
tool: "gog_sheets_datasource_update",
|
|
37571
|
+
action: "sheets.datasource-update",
|
|
37572
|
+
account,
|
|
37573
|
+
confirmToken,
|
|
37574
|
+
spreadsheetId,
|
|
37575
|
+
target: `${spreadsheetId}/${dataSourceId}`,
|
|
37576
|
+
view: { dataSourceId, changes }
|
|
37577
|
+
});
|
|
37578
|
+
if (confirmation) return confirmation;
|
|
37013
37579
|
return runOrDiagnose(args, { account });
|
|
37014
37580
|
});
|
|
37015
37581
|
server.registerTool("gog_sheets_datasource_refresh", {
|
|
@@ -37019,11 +37585,24 @@ ${errorText(err)}`
|
|
|
37019
37585
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
37020
37586
|
dataSourceId: external_exports.string().describe("Data source ID, as reported by gog_sheets_datasource_list"),
|
|
37021
37587
|
forceRefresh: external_exports.boolean().optional().describe("Refresh even when the previous execution failed"),
|
|
37022
|
-
account: accountParam
|
|
37588
|
+
account: accountParam,
|
|
37589
|
+
confirmToken: confirmTokenParam
|
|
37023
37590
|
})
|
|
37024
|
-
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
|
|
37591
|
+
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account, confirmToken }, ctx) => {
|
|
37025
37592
|
const args = ["sheets", "datasource", "refresh", pos(spreadsheetId), pos(dataSourceId)];
|
|
37026
37593
|
if (forceRefresh) args.push("--force-refresh");
|
|
37594
|
+
const described = await runOrDiagnose(["sheets", "datasource", "describe", pos(spreadsheetId), pos(dataSourceId)], { account });
|
|
37595
|
+
if (described.isError) return described;
|
|
37596
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
37597
|
+
tool: "gog_sheets_datasource_refresh",
|
|
37598
|
+
action: "sheets.datasource-refresh",
|
|
37599
|
+
account,
|
|
37600
|
+
confirmToken,
|
|
37601
|
+
spreadsheetId,
|
|
37602
|
+
target: `${spreadsheetId}/${dataSourceId}`,
|
|
37603
|
+
view: { dataSourceId, ...dataSourceSnapshot(resultText2(described)), forceRefresh: Boolean(forceRefresh) }
|
|
37604
|
+
});
|
|
37605
|
+
if (confirmation) return confirmation;
|
|
37027
37606
|
return runOrDiagnose(args, { account });
|
|
37028
37607
|
});
|
|
37029
37608
|
server.registerTool("gog_sheets_datasource_delete", {
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-sheets",
|
|
5
5
|
"display_name": "gogcli (Sheets)",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.5.1",
|
|
7
7
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -349,15 +349,15 @@
|
|
|
349
349
|
},
|
|
350
350
|
{
|
|
351
351
|
"name": "gog_sheets_datasource_add",
|
|
352
|
-
"description": "Add a BigQuery Connected Sheets data source (custom SQL or a native table)"
|
|
352
|
+
"description": "Add a BigQuery Connected Sheets data source (custom SQL or a native table) (billed; asks the user to confirm)"
|
|
353
353
|
},
|
|
354
354
|
{
|
|
355
355
|
"name": "gog_sheets_datasource_update",
|
|
356
|
-
"description": "Repoint a Connected Sheets data source's SQL, table or billing project"
|
|
356
|
+
"description": "Repoint a Connected Sheets data source's SQL, table or billing project (billed; asks the user to confirm)"
|
|
357
357
|
},
|
|
358
358
|
{
|
|
359
359
|
"name": "gog_sheets_datasource_refresh",
|
|
360
|
-
"description": "Re-run a Connected Sheets data source against current BigQuery data"
|
|
360
|
+
"description": "Re-run a Connected Sheets data source against current BigQuery data (billed; asks the user to confirm)"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"name": "gog_sheets_datasource_delete",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-sheets",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.1",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-sheets",
|
|
5
5
|
"description": "Extended Google Sheets MCP server via gogcli — all base tools plus full Sheets support",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test:coverage": "vitest run --coverage"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@chrischall/mcp-utils": "^2.
|
|
27
|
+
"@chrischall/mcp-utils": "^2.6.0",
|
|
28
28
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
29
29
|
"zod": "^4.6.1"
|
|
30
30
|
},
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-sheets"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.
|
|
10
|
+
"version": "4.5.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-sheets",
|
|
15
|
-
"version": "4.
|
|
15
|
+
"version": "4.5.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -2,7 +2,20 @@ import { McpServer } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
accountParam,
|
|
7
|
+
runOrDiagnose,
|
|
8
|
+
run,
|
|
9
|
+
diagnose,
|
|
10
|
+
errorText,
|
|
11
|
+
payloadArg,
|
|
12
|
+
pos,
|
|
13
|
+
confinePath,
|
|
14
|
+
confineAtFile,
|
|
15
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
16
|
+
confirmTokenParam,
|
|
17
|
+
requireDispatchConfirmation,
|
|
18
|
+
} from '../../../gogcli-mcp/src/lib.js';
|
|
6
19
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
20
|
|
|
8
21
|
// Pull the text out of a single-text-block tool result; undefined for any
|
|
@@ -12,6 +25,44 @@ function resultText(result: CallToolResult): string | undefined {
|
|
|
12
25
|
return first?.type === 'text' ? first.text : undefined;
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
/** A spreadsheet's title from `gog sheets metadata --select=properties.title`. Unreadable output names nothing. */
|
|
29
|
+
export function spreadsheetTitle(raw: string | undefined): string | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(String(raw)) as { properties?: { title?: unknown }; spreadsheet?: { properties?: { title?: unknown } } } | null;
|
|
32
|
+
const title = parsed?.spreadsheet?.properties?.title ?? parsed?.properties?.title;
|
|
33
|
+
return typeof title === 'string' ? title : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a data source queries and who pays, from `gog sheets datasource describe`,
|
|
41
|
+
* for a refresh's prompt. Best effort: only the fields found are named.
|
|
42
|
+
*/
|
|
43
|
+
export function dataSourceSnapshot(raw: string | undefined): { billingProject?: string; query?: string; table?: string } {
|
|
44
|
+
type BigQuerySpec = {
|
|
45
|
+
projectId?: unknown;
|
|
46
|
+
querySpec?: { rawQuery?: unknown };
|
|
47
|
+
tableSpec?: { tableProjectId?: unknown; datasetId?: unknown; tableId?: unknown };
|
|
48
|
+
};
|
|
49
|
+
let bq: BigQuerySpec | undefined;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(String(raw)) as ({ dataSource?: { spec?: { bigQuery?: BigQuerySpec } }; spec?: { bigQuery?: BigQuerySpec } }) | null;
|
|
52
|
+
bq = (parsed?.dataSource ?? parsed)?.spec?.bigQuery;
|
|
53
|
+
} catch {
|
|
54
|
+
bq = undefined;
|
|
55
|
+
}
|
|
56
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
|
57
|
+
const t = bq?.tableSpec;
|
|
58
|
+
const table = [t?.tableProjectId, t?.datasetId, t?.tableId].map(str).filter(Boolean).join('.');
|
|
59
|
+
return {
|
|
60
|
+
...(str(bq?.projectId) ? { billingProject: str(bq?.projectId) } : {}),
|
|
61
|
+
...(str(bq?.querySpec?.rawQuery) ? { query: str(bq?.querySpec?.rawQuery) } : {}),
|
|
62
|
+
...(table ? { table } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
15
66
|
// Convert a CSS-style hex color ("#FFF5D9", "#FD9", "FFF5D9") to the
|
|
16
67
|
// {red, green, blue} 0-1 float triple that Sheets API CellFormat expects.
|
|
17
68
|
// Returns null on unparseable input — caller decides whether to fall back
|
|
@@ -77,6 +128,42 @@ async function checkDateFormatTarget(
|
|
|
77
128
|
);
|
|
78
129
|
}
|
|
79
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Fleet audit 2026-09-24 SEC-6: every datasource add / update / refresh starts
|
|
133
|
+
* a BigQuery job billed to a project, so each asks first — naming the
|
|
134
|
+
* spreadsheet by title, what will be queried and who pays.
|
|
135
|
+
*/
|
|
136
|
+
async function confirmBilledExecution(
|
|
137
|
+
ctx: Parameters<typeof requireDispatchConfirmation>[0],
|
|
138
|
+
o: {
|
|
139
|
+
tool: string;
|
|
140
|
+
action: string;
|
|
141
|
+
account: string | undefined;
|
|
142
|
+
confirmToken: string | undefined;
|
|
143
|
+
spreadsheetId: string;
|
|
144
|
+
target: string;
|
|
145
|
+
view: Record<string, unknown>;
|
|
146
|
+
},
|
|
147
|
+
) {
|
|
148
|
+
const got = await runOrDiagnose(['sheets', 'metadata', pos(o.spreadsheetId), '--select=properties.title'], { account: o.account });
|
|
149
|
+
if (got.isError) return got;
|
|
150
|
+
const title = spreadsheetTitle(resultText(got));
|
|
151
|
+
const view = { spreadsheet: { id: o.spreadsheetId, ...(title !== undefined ? { title } : {}) }, ...o.view };
|
|
152
|
+
return requireDispatchConfirmation(ctx, {
|
|
153
|
+
action: o.action,
|
|
154
|
+
message: 'Review and confirm starting a BigQuery execution — it is billed to the billing project:',
|
|
155
|
+
confirmationLabel: 'Confirm that this billed BigQuery query should run now.',
|
|
156
|
+
details: view,
|
|
157
|
+
unsupportedNote: 'Ask the user to run it from Google Sheets (Data > Data connectors).',
|
|
158
|
+
fallback: {
|
|
159
|
+
tool: o.tool,
|
|
160
|
+
account: o.account,
|
|
161
|
+
confirmToken: o.confirmToken,
|
|
162
|
+
subject: () => ({ target: o.target, payload: view, preview: view }),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
80
167
|
export function registerExtraSheetsTools(server: McpServer): void {
|
|
81
168
|
|
|
82
169
|
server.registerTool('gog_sheets_list_tabs', {
|
|
@@ -1144,7 +1231,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1144
1231
|
const bigQueryChargeNote =
|
|
1145
1232
|
' COSTS MONEY AND RUNS ASYNCHRONOUSLY: this starts a BigQuery execution billed to the billing project, and returns ' +
|
|
1146
1233
|
'once the execution has been REQUESTED — not once it has finished. Poll gog_sheets_datasource_describe until its ' +
|
|
1147
|
-
'dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current.'
|
|
1234
|
+
'dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current. Because it bills, it first asks ' +
|
|
1235
|
+
'the MCP host to show the user a confirmation prompt naming the spreadsheet, the query or table and the billing ' +
|
|
1236
|
+
'project; nothing runs unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION;
|
|
1148
1237
|
|
|
1149
1238
|
server.registerTool('gog_sheets_datasource_add', {
|
|
1150
1239
|
description:
|
|
@@ -1162,8 +1251,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1162
1251
|
table: z.string().optional().describe('BigQuery table ID. Requires dataset; mutually exclusive with query.'),
|
|
1163
1252
|
tableProject: z.string().optional().describe('BigQuery project that owns the table (default: the billing project). Only valid with dataset/table.'),
|
|
1164
1253
|
account: accountParam,
|
|
1254
|
+
confirmToken: confirmTokenParam,
|
|
1165
1255
|
}),
|
|
1166
|
-
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
1256
|
+
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
1167
1257
|
const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
|
|
1168
1258
|
if (query !== undefined && tableFlags) {
|
|
1169
1259
|
throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
|
|
@@ -1176,6 +1266,12 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1176
1266
|
if (tableProject) args.push(`--table-project=${tableProject}`);
|
|
1177
1267
|
if (dataset) args.push(`--dataset=${dataset}`);
|
|
1178
1268
|
if (table) args.push(`--table=${table}`);
|
|
1269
|
+
const source = query !== undefined ? { query } : { table: [tableProject ?? billingProject, dataset, table].join('.') };
|
|
1270
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1271
|
+
tool: 'gog_sheets_datasource_add', action: 'sheets.datasource-add', account, confirmToken, spreadsheetId,
|
|
1272
|
+
target: spreadsheetId, view: { billingProject, ...source },
|
|
1273
|
+
});
|
|
1274
|
+
if (confirmation) return confirmation;
|
|
1179
1275
|
return runOrDiagnose(args, { account });
|
|
1180
1276
|
});
|
|
1181
1277
|
|
|
@@ -1196,8 +1292,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1196
1292
|
table: z.string().optional().describe('Replacement table ID. Only valid on a table-backed source.'),
|
|
1197
1293
|
tableProject: z.string().optional().describe('Replacement project owning the table. Only valid on a table-backed source.'),
|
|
1198
1294
|
account: accountParam,
|
|
1295
|
+
confirmToken: confirmTokenParam,
|
|
1199
1296
|
}),
|
|
1200
|
-
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
1297
|
+
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
1201
1298
|
const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
|
|
1202
1299
|
if (query !== undefined && tableFlags) {
|
|
1203
1300
|
throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
|
|
@@ -1211,6 +1308,12 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1211
1308
|
if (tableProject !== undefined) args.push(`--table-project=${tableProject}`);
|
|
1212
1309
|
if (dataset !== undefined) args.push(`--dataset=${dataset}`);
|
|
1213
1310
|
if (table !== undefined) args.push(`--table=${table}`);
|
|
1311
|
+
const changes = Object.fromEntries(Object.entries({ billingProject, query, tableProject, dataset, table }).filter(([, v]) => v !== undefined));
|
|
1312
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1313
|
+
tool: 'gog_sheets_datasource_update', action: 'sheets.datasource-update', account, confirmToken, spreadsheetId,
|
|
1314
|
+
target: `${spreadsheetId}/${dataSourceId}`, view: { dataSourceId, changes },
|
|
1315
|
+
});
|
|
1316
|
+
if (confirmation) return confirmation;
|
|
1214
1317
|
return runOrDiagnose(args, { account });
|
|
1215
1318
|
});
|
|
1216
1319
|
|
|
@@ -1240,10 +1343,19 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1240
1343
|
dataSourceId: z.string().describe('Data source ID, as reported by gog_sheets_datasource_list'),
|
|
1241
1344
|
forceRefresh: z.boolean().optional().describe('Refresh even when the previous execution failed'),
|
|
1242
1345
|
account: accountParam,
|
|
1346
|
+
confirmToken: confirmTokenParam,
|
|
1243
1347
|
}),
|
|
1244
|
-
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
|
|
1348
|
+
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account, confirmToken }, ctx) => {
|
|
1245
1349
|
const args: GogArg[] = ['sheets', 'datasource', 'refresh', pos(spreadsheetId), pos(dataSourceId)];
|
|
1246
1350
|
if (forceRefresh) args.push('--force-refresh');
|
|
1351
|
+
const described = await runOrDiagnose(['sheets', 'datasource', 'describe', pos(spreadsheetId), pos(dataSourceId)], { account });
|
|
1352
|
+
if (described.isError) return described;
|
|
1353
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1354
|
+
tool: 'gog_sheets_datasource_refresh', action: 'sheets.datasource-refresh', account, confirmToken, spreadsheetId,
|
|
1355
|
+
target: `${spreadsheetId}/${dataSourceId}`,
|
|
1356
|
+
view: { dataSourceId, ...dataSourceSnapshot(resultText(described)), forceRefresh: Boolean(forceRefresh) },
|
|
1357
|
+
});
|
|
1358
|
+
if (confirmation) return confirmation;
|
|
1247
1359
|
return runOrDiagnose(args, { account });
|
|
1248
1360
|
});
|
|
1249
1361
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerExtraSheetsTools, dataSourceSnapshot, spreadsheetTitle } from '../../src/tools/sheets-extra.js';
|
|
3
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
6
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
7
|
+
import { pos } from '../../../gogcli-mcp/src/argv.js';
|
|
8
|
+
import { resetConfirmTokenState } from '../../../gogcli-mcp/src/send-confirm-token.js';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof lib>();
|
|
12
|
+
return { ...actual, runOrDiagnose: vi.fn() };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Fleet audit 2026-09-24 SEC-6: datasource add / update / refresh each start a
|
|
16
|
+
// BigQuery job billed to a project, so each asks first — naming the spreadsheet,
|
|
17
|
+
// what is queried and who pays.
|
|
18
|
+
|
|
19
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
process.env = { ...ORIGINAL_ENV };
|
|
23
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
24
|
+
resetConfirmTokenState();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
27
|
+
|
|
28
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
29
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
30
|
+
const seen: ElicitRequest[] = [];
|
|
31
|
+
const harness = await createTestHarness(registerExtraSheetsTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
32
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
33
|
+
return { harness, seen, details };
|
|
34
|
+
}
|
|
35
|
+
const unprompted = () => createTestHarness(registerExtraSheetsTools);
|
|
36
|
+
|
|
37
|
+
const META = JSON.stringify({ properties: { title: 'Sales dashboard' } });
|
|
38
|
+
const DESCRIBE = JSON.stringify({ dataSource: { dataSourceId: 'ds1', spec: { bigQuery: { projectId: 'billing-1', querySpec: { rawQuery: 'SELECT 1' } } } } });
|
|
39
|
+
function stub(failing?: string) {
|
|
40
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
41
|
+
const key = args.filter((a): a is string => typeof a === 'string' && !a.startsWith('--')).join(' ');
|
|
42
|
+
if (failing && key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
43
|
+
if (key === 'sheets metadata') return rawTextResult(META);
|
|
44
|
+
if (key === 'sheets datasource describe') return rawTextResult(DESCRIBE);
|
|
45
|
+
return rawTextResult('{"ok":true}');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const callsTo = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
|
|
49
|
+
words.every((w, i) => args[i] === w));
|
|
50
|
+
|
|
51
|
+
const SHEET = { id: 'sid', title: 'Sales dashboard' };
|
|
52
|
+
const CASES = [
|
|
53
|
+
{
|
|
54
|
+
tool: 'gog_sheets_datasource_add',
|
|
55
|
+
args: { spreadsheetId: 'sid', billingProject: 'billing-1', dataset: 'sales', table: 'orders' },
|
|
56
|
+
action: 'sheets.datasource-add',
|
|
57
|
+
details: { spreadsheet: SHEET, billingProject: 'billing-1', table: 'billing-1.sales.orders' },
|
|
58
|
+
write: 'add',
|
|
59
|
+
reads: ['sheets metadata'],
|
|
60
|
+
changed: { table: 'refunds' },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
tool: 'gog_sheets_datasource_update',
|
|
64
|
+
args: { spreadsheetId: 'sid', dataSourceId: 'ds1', query: 'SELECT 2', billingProject: 'billing-2' },
|
|
65
|
+
action: 'sheets.datasource-update',
|
|
66
|
+
details: { spreadsheet: SHEET, dataSourceId: 'ds1', changes: { billingProject: 'billing-2', query: 'SELECT 2' } },
|
|
67
|
+
write: 'update',
|
|
68
|
+
reads: ['sheets metadata'],
|
|
69
|
+
changed: { query: 'SELECT 3' },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
tool: 'gog_sheets_datasource_refresh',
|
|
73
|
+
args: { spreadsheetId: 'sid', dataSourceId: 'ds1', forceRefresh: true },
|
|
74
|
+
action: 'sheets.datasource-refresh',
|
|
75
|
+
details: { spreadsheet: SHEET, dataSourceId: 'ds1', billingProject: 'billing-1', query: 'SELECT 1', forceRefresh: true },
|
|
76
|
+
write: 'refresh',
|
|
77
|
+
reads: ['sheets datasource describe', 'sheets metadata'],
|
|
78
|
+
changed: { forceRefresh: false },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
describe.each(CASES)('$tool', (c) => {
|
|
83
|
+
it('asks naming the spreadsheet, the source and who pays; runs once accepted', async () => {
|
|
84
|
+
stub();
|
|
85
|
+
const { harness, details, seen } = await prompted();
|
|
86
|
+
await harness.callTool(c.tool, c.args);
|
|
87
|
+
expect(seen[0]!.params.message).toMatch(/billed/);
|
|
88
|
+
expect(details()).toEqual(c.details);
|
|
89
|
+
expect(callsTo('sheets', 'metadata')[0]![0]).toEqual(['sheets', 'metadata', pos('sid'), '--select=properties.title']);
|
|
90
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('runs nothing when the user declines', async () => {
|
|
94
|
+
stub();
|
|
95
|
+
expect(json(await (await prompted({ action: 'decline' })).harness.callTool(c.tool, c.args)))
|
|
96
|
+
.toMatchObject({ cancelled: true, action: c.action });
|
|
97
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each(c.reads)('returns a failed read (%s) without asking or running', async (failing) => {
|
|
101
|
+
stub(failing);
|
|
102
|
+
const { harness, seen } = await prompted();
|
|
103
|
+
expect((await harness.callTool(c.tool, c.args)).isError).toBe(true);
|
|
104
|
+
expect(seen).toHaveLength(0);
|
|
105
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses a client that cannot be prompted', async () => {
|
|
109
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
110
|
+
stub();
|
|
111
|
+
const r = json(await (await unprompted()).callTool(c.tool, c.args));
|
|
112
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: c.action });
|
|
113
|
+
expect(r.note).toMatch(/Data connectors/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('token fallback: changed arguments are refused, the same call runs', async () => {
|
|
117
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
118
|
+
stub();
|
|
119
|
+
const harness = await unprompted();
|
|
120
|
+
const p1 = json(await harness.callTool(c.tool, c.args));
|
|
121
|
+
expect(p1.preview).toEqual(c.details);
|
|
122
|
+
expect(json(await harness.callTool(c.tool, { ...c.args, ...c.changed, confirmToken: p1.confirmToken })))
|
|
123
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
124
|
+
await harness.callTool(c.tool, { ...c.args, confirmToken: p1.confirmToken });
|
|
125
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a query-backed add previews the SQL', async () => {
|
|
130
|
+
stub();
|
|
131
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
132
|
+
await harness.callTool('gog_sheets_datasource_add', { spreadsheetId: 'sid', billingProject: 'b', query: 'SELECT 9', });
|
|
133
|
+
expect(details()).toEqual({ spreadsheet: SHEET, billingProject: 'b', query: 'SELECT 9' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a table in another project names that project', async () => {
|
|
137
|
+
stub();
|
|
138
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
139
|
+
await harness.callTool('gog_sheets_datasource_add', { spreadsheetId: 'sid', billingProject: 'b', tableProject: 'p', dataset: 'd', table: 't' });
|
|
140
|
+
expect(details().table).toBe('p.d.t');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('parsers', () => {
|
|
144
|
+
it('spreadsheetTitle reads either shape and tolerates unreadable output', () => {
|
|
145
|
+
expect(spreadsheetTitle(JSON.stringify({ spreadsheet: { properties: { title: 'A' } } }))).toBe('A');
|
|
146
|
+
expect(spreadsheetTitle(JSON.stringify({ properties: { title: 7 } }))).toBeUndefined();
|
|
147
|
+
expect(spreadsheetTitle('null')).toBeUndefined();
|
|
148
|
+
expect(spreadsheetTitle(undefined)).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('dataSourceSnapshot names a table source, a bare spec, and nothing for unreadable output', () => {
|
|
152
|
+
expect(dataSourceSnapshot(JSON.stringify({ spec: { bigQuery: { tableSpec: { tableProjectId: 'p', datasetId: 'd', tableId: 't' } } } })))
|
|
153
|
+
.toEqual({ table: 'p.d.t' });
|
|
154
|
+
expect(dataSourceSnapshot(JSON.stringify({ dataSource: { spec: { bigQuery: { projectId: 7 } } } }))).toEqual({});
|
|
155
|
+
expect(dataSourceSnapshot('null')).toEqual({});
|
|
156
|
+
expect(dataSourceSnapshot('nope')).toEqual({});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -15,7 +15,10 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
15
15
|
};
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// The billed datasource tools ask first (sheets-extra-gates.test.ts covers the prompts); here the user accepts.
|
|
19
|
+
const setupHandlers = () => createTestHarness(registerExtraSheetsTools, {
|
|
20
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
21
|
+
});
|
|
19
22
|
|
|
20
23
|
beforeEach(() => vi.clearAllMocks());
|
|
21
24
|
|