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.
@@ -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.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.5.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.5.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,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 sign = minutes < 0 ? "-" : "+";
35200
+ const sign2 = minutes < 0 ? "-" : "+";
34789
35201
  const abs = Math.abs(minutes);
34790
- return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
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.0" : "0.0.0";
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 = createHash("sha256").update(values.join("\0")).digest("hex");
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.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.5.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>",
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.0",
10
+ "version": "4.5.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "4.5.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