gogcli-mcp-sheets 4.5.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 +576 -17
- package/manifest.json +4 -4
- package/package.json +1 -1
- 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.5.
|
|
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.5.
|
|
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,7 +34531,160 @@ 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
|
+
|
|
34471
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";
|
|
34472
34688
|
function createSpentTokenStore() {
|
|
34473
34689
|
const spent2 = /* @__PURE__ */ new Map();
|
|
34474
34690
|
return {
|
|
@@ -34488,7 +34704,148 @@ function createSpentTokenStore() {
|
|
|
34488
34704
|
};
|
|
34489
34705
|
}
|
|
34490
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.";
|
|
34491
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";
|
|
34492
34849
|
|
|
34493
34850
|
// ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
34494
34851
|
import { homedir } from "node:os";
|
|
@@ -34529,6 +34886,61 @@ function expandPath(p) {
|
|
|
34529
34886
|
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
34530
34887
|
}
|
|
34531
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
|
+
|
|
34532
34944
|
// ../../node_modules/@chrischall/mcp-utils/dist/server/index.js
|
|
34533
34945
|
var SERVER_PROTOCOL_VERSIONS = Object.freeze([
|
|
34534
34946
|
"2026-07-28",
|
|
@@ -34785,9 +35197,9 @@ function offsetAt(instant, tz) {
|
|
|
34785
35197
|
const w = wallPartsIn(instant, tz);
|
|
34786
35198
|
const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
|
|
34787
35199
|
const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
|
|
34788
|
-
const
|
|
35200
|
+
const sign2 = minutes < 0 ? "-" : "+";
|
|
34789
35201
|
const abs = Math.abs(minutes);
|
|
34790
|
-
return `${
|
|
35202
|
+
return `${sign2}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
34791
35203
|
}
|
|
34792
35204
|
function wallPartsIn(instant, tz) {
|
|
34793
35205
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
@@ -35492,6 +35904,7 @@ async function diagnose(err) {
|
|
|
35492
35904
|
const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
|
|
35493
35905
|
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
35494
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}`);
|
|
35495
35908
|
try {
|
|
35496
35909
|
const accounts = formatAccountList(await run(["auth", "list"]));
|
|
35497
35910
|
return errorResult(`${errText}
|
|
@@ -35582,6 +35995,42 @@ function formatAuthHealth(raw, now) {
|
|
|
35582
35995
|
|
|
35583
35996
|
// ../gogcli-mcp/src/send-confirm-token.ts
|
|
35584
35997
|
var spent = createSpentTokenStore();
|
|
35998
|
+
function confirmSpentStore() {
|
|
35999
|
+
return spent;
|
|
36000
|
+
}
|
|
36001
|
+
|
|
36002
|
+
// ../gogcli-mcp/src/dispatch-confirmation.ts
|
|
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
|
+
}
|
|
35585
36034
|
|
|
35586
36035
|
// ../gogcli-mcp/src/tools/auth.ts
|
|
35587
36036
|
var AUTH_RUN_SUBCOMMANDS = ["list", "status", "services", "remove", "alias"];
|
|
@@ -35718,6 +36167,27 @@ function authToolsFor(defaultServices) {
|
|
|
35718
36167
|
}
|
|
35719
36168
|
|
|
35720
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
|
+
};
|
|
35721
36191
|
var reminderParams = {
|
|
35722
36192
|
reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
|
|
35723
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.`
|
|
@@ -35819,6 +36289,13 @@ function countNonEmptyCells(getOutput) {
|
|
|
35819
36289
|
}
|
|
35820
36290
|
|
|
35821
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
|
+
}
|
|
35822
36299
|
var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
|
|
35823
36300
|
var dryRunParam = external_exports.boolean().optional().describe(
|
|
35824
36301
|
"Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
|
|
@@ -35934,11 +36411,11 @@ function registerSheetsTools(server) {
|
|
|
35934
36411
|
}, async ({ spreadsheetId, find, replace, account }) => {
|
|
35935
36412
|
return runOrDiagnose(["sheets", "find-replace", pos(spreadsheetId), pos(find), pos(replace)], { account });
|
|
35936
36413
|
});
|
|
35937
|
-
registerRunTool(server, { service: "sheets", examples: '"freeze", "add-tab", "rename-tab"' });
|
|
36414
|
+
registerRunTool(server, { service: "sheets", examples: '"freeze", "add-tab", "rename-tab"', vet: vetSheetsRun });
|
|
35938
36415
|
}
|
|
35939
36416
|
|
|
35940
36417
|
// ../gogcli-mcp/src/server.ts
|
|
35941
|
-
var VERSION = true ? "4.5.
|
|
36418
|
+
var VERSION = true ? "4.5.1" : "0.0.0";
|
|
35942
36419
|
|
|
35943
36420
|
// ../gogcli-mcp/src/bootstrap-auth.ts
|
|
35944
36421
|
import { join as join5 } from "node:path";
|
|
@@ -35975,12 +36452,12 @@ async function bootstrapGogAuth(env = process.env, options = {}) {
|
|
|
35975
36452
|
const [clientId, clientSecret, refreshToken, account] = values;
|
|
35976
36453
|
const { spawner } = options;
|
|
35977
36454
|
try {
|
|
35978
|
-
const { createHash } = await import("node:crypto");
|
|
36455
|
+
const { createHash: createHash4 } = await import("node:crypto");
|
|
35979
36456
|
const { mkdir, readFile, writeFile, chmod } = await import("node:fs/promises");
|
|
35980
36457
|
const home = options.home ?? (await import("node:os")).homedir();
|
|
35981
36458
|
const dir = join5(home, ".gogcli-mcp");
|
|
35982
36459
|
const marker = join5(dir, AUTH_BOOTSTRAP_MARKER);
|
|
35983
|
-
const fingerprint =
|
|
36460
|
+
const fingerprint = createHash4("sha256").update(values.join("\0")).digest("hex");
|
|
35984
36461
|
const previous = await readFile(marker, "utf8").catch(() => void 0);
|
|
35985
36462
|
if (previous === fingerprint && await accountListed(account, spawner)) return "present";
|
|
35986
36463
|
await run(
|
|
@@ -36017,6 +36494,32 @@ function resultText2(result) {
|
|
|
36017
36494
|
const first = result.content[0];
|
|
36018
36495
|
return first?.type === "text" ? first.text : void 0;
|
|
36019
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
|
+
}
|
|
36020
36523
|
function hexToRgb(hex3) {
|
|
36021
36524
|
let h = hex3.trim().replace(/^#/, "");
|
|
36022
36525
|
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
@@ -36055,6 +36558,25 @@ async function checkDateFormatTarget(spreadsheetId, range, account) {
|
|
|
36055
36558
|
if (!sawSmallInt) return null;
|
|
36056
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.";
|
|
36057
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
|
+
}
|
|
36058
36580
|
function registerExtraSheetsTools(server) {
|
|
36059
36581
|
server.registerTool("gog_sheets_list_tabs", {
|
|
36060
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.",
|
|
@@ -36975,7 +37497,7 @@ ${errorText(err)}`
|
|
|
36975
37497
|
if (render) args.push(`--render=${render}`);
|
|
36976
37498
|
return runOrDiagnose(args, { account });
|
|
36977
37499
|
});
|
|
36978
|
-
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;
|
|
36979
37501
|
server.registerTool("gog_sheets_datasource_add", {
|
|
36980
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,
|
|
36981
37503
|
annotations: { destructiveHint: false },
|
|
@@ -36986,9 +37508,10 @@ ${errorText(err)}`
|
|
|
36986
37508
|
dataset: external_exports.string().optional().describe("BigQuery dataset ID of an existing table. Requires table; mutually exclusive with query."),
|
|
36987
37509
|
table: external_exports.string().optional().describe("BigQuery table ID. Requires dataset; mutually exclusive with query."),
|
|
36988
37510
|
tableProject: external_exports.string().optional().describe("BigQuery project that owns the table (default: the billing project). Only valid with dataset/table."),
|
|
36989
|
-
account: accountParam
|
|
37511
|
+
account: accountParam,
|
|
37512
|
+
confirmToken: confirmTokenParam
|
|
36990
37513
|
})
|
|
36991
|
-
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
37514
|
+
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
36992
37515
|
const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
|
|
36993
37516
|
if (query !== void 0 && tableFlags) {
|
|
36994
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.");
|
|
@@ -37001,6 +37524,17 @@ ${errorText(err)}`
|
|
|
37001
37524
|
if (tableProject) args.push(`--table-project=${tableProject}`);
|
|
37002
37525
|
if (dataset) args.push(`--dataset=${dataset}`);
|
|
37003
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;
|
|
37004
37538
|
return runOrDiagnose(args, { account });
|
|
37005
37539
|
});
|
|
37006
37540
|
server.registerTool("gog_sheets_datasource_update", {
|
|
@@ -37014,9 +37548,10 @@ ${errorText(err)}`
|
|
|
37014
37548
|
dataset: external_exports.string().optional().describe("Replacement dataset ID. Only valid on a table-backed source."),
|
|
37015
37549
|
table: external_exports.string().optional().describe("Replacement table ID. Only valid on a table-backed source."),
|
|
37016
37550
|
tableProject: external_exports.string().optional().describe("Replacement project owning the table. Only valid on a table-backed source."),
|
|
37017
|
-
account: accountParam
|
|
37551
|
+
account: accountParam,
|
|
37552
|
+
confirmToken: confirmTokenParam
|
|
37018
37553
|
})
|
|
37019
|
-
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
37554
|
+
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
37020
37555
|
const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
|
|
37021
37556
|
if (query !== void 0 && tableFlags) {
|
|
37022
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.");
|
|
@@ -37030,6 +37565,17 @@ ${errorText(err)}`
|
|
|
37030
37565
|
if (tableProject !== void 0) args.push(`--table-project=${tableProject}`);
|
|
37031
37566
|
if (dataset !== void 0) args.push(`--dataset=${dataset}`);
|
|
37032
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;
|
|
37033
37579
|
return runOrDiagnose(args, { account });
|
|
37034
37580
|
});
|
|
37035
37581
|
server.registerTool("gog_sheets_datasource_refresh", {
|
|
@@ -37039,11 +37585,24 @@ ${errorText(err)}`
|
|
|
37039
37585
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
37040
37586
|
dataSourceId: external_exports.string().describe("Data source ID, as reported by gog_sheets_datasource_list"),
|
|
37041
37587
|
forceRefresh: external_exports.boolean().optional().describe("Refresh even when the previous execution failed"),
|
|
37042
|
-
account: accountParam
|
|
37588
|
+
account: accountParam,
|
|
37589
|
+
confirmToken: confirmTokenParam
|
|
37043
37590
|
})
|
|
37044
|
-
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
|
|
37591
|
+
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account, confirmToken }, ctx) => {
|
|
37045
37592
|
const args = ["sheets", "datasource", "refresh", pos(spreadsheetId), pos(dataSourceId)];
|
|
37046
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;
|
|
37047
37606
|
return runOrDiagnose(args, { account });
|
|
37048
37607
|
});
|
|
37049
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.5.
|
|
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.5.
|
|
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>",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-sheets"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.5.
|
|
10
|
+
"version": "4.5.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-sheets",
|
|
15
|
-
"version": "4.5.
|
|
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
|
|