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.
@@ -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.4.0"
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.4.0",
18
+ "version": "4.5.1",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp-sheets",
3
3
  "displayName": "gogcli (Sheets)",
4
- "version": "4.4.0",
4
+ "version": "4.5.1",
5
5
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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 rejection2 = modernOnlyStrictRejection({
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 (rejection2 === void 0) return Promise.resolve();
34157
- reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection2.cell}): ${rejection2.message}`));
34158
- return writeErrorResponse(request.id, rejection2.code, rejection2.message, rejection2.data);
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 sign = minutes < 0 ? "-" : "+";
35200
+ const sign2 = minutes < 0 ? "-" : "+";
34767
35201
  const abs = Math.abs(minutes);
34768
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
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 confirmTokenParam = external_exports.string().optional().describe(
35563
- `ONLY for the two-step fallback (client without MCP elicitation, server with GOG_SEND_CONFIRM_FALLBACK=token). 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.`
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.4.0" : "0.0.0";
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 = createHash("sha256").update(values.join("\0")).digest("hex");
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.4.0",
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.4.0",
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.4.0",
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.4.0",
10
+ "version": "4.5.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "4.4.0",
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 { accountParam, runOrDiagnose, run, diagnose, errorText, payloadArg, pos, confinePath, confineAtFile } from '../../../gogcli-mcp/src/lib.js';
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
- const setupHandlers = () => createTestHarness(registerExtraSheetsTools);
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