gogcli-mcp-sheets 2.25.0 → 2.27.0

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": "2.25.0"
10
+ "version": "2.27.0"
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": "2.25.0",
18
+ "version": "2.27.0",
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": "2.25.0",
4
+ "version": "2.27.0",
5
5
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -44,9 +44,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
44
44
  claude mcp add gogcli-sheets -- gogcli-mcp-sheets
45
45
  ```
46
46
 
47
- ## Extra Sheets Tools (55)
47
+ ## Extra Sheets Tools (59)
48
48
 
49
- Plus 8 auth tools and 8 base Sheets tools (get, update, append, clear, metadata, create, find-replace, run) — 71 in all.
49
+ Plus 8 auth tools and 8 base Sheets tools (get, update, append, clear, metadata, create, find-replace, run) — 75 in all.
50
50
 
51
51
  | Tool | Description |
52
52
  |------|-------------|
@@ -79,6 +79,10 @@ Plus 8 auth tools and 8 base Sheets tools (get, update, append, clear, metadata,
79
79
  | `gog_sheets_datasource_table_list` | List anchored data-source tables (extracts) and their A1 anchors |
80
80
  | `gog_sheets_datasource_table_describe` | Describe the extract anchored at an A1 cell |
81
81
  | `gog_sheets_datasource_table_read` | Read a bounded number of rows out of an extract |
82
+ | `gog_sheets_datasource_add` | Add a BigQuery Connected Sheets data source (custom SQL or a native table) |
83
+ | `gog_sheets_datasource_update` | Repoint a data source's SQL, table or billing project |
84
+ | `gog_sheets_datasource_refresh` | Re-run a data source so its sheet and extracts pick up current data |
85
+ | `gog_sheets_datasource_delete` | Delete a data source, its linked sheet, and unlink dependent objects |
82
86
  | `gog_sheets_list_tabs` | List tabs in a spreadsheet (sheetId, title, index, gridProperties) |
83
87
  | `gog_sheets_copy_paste` | Copy a range's values/formulas/format to another range (tiles to fill down/across). |
84
88
  | `gog_sheets_validation_get` | Read data-validation rules (dropdowns, checkboxes, conditions) on a range. |
package/SKILL.md CHANGED
@@ -58,6 +58,10 @@ Extended Google Sheets MCP server via [gogcli](https://github.com/openclaw/gogcl
58
58
  | `gog_sheets_datasource_table_list` | List anchored data-source tables (extracts) and their A1 anchors |
59
59
  | `gog_sheets_datasource_table_describe` | Describe the extract anchored at an A1 cell |
60
60
  | `gog_sheets_datasource_table_read` | Read a bounded number of rows out of an extract |
61
+ | `gog_sheets_datasource_add` | Add a BigQuery Connected Sheets data source (custom SQL or a native table) |
62
+ | `gog_sheets_datasource_update` | Repoint a data source's SQL, table or billing project |
63
+ | `gog_sheets_datasource_refresh` | Re-run a data source so its sheet and extracts pick up current data |
64
+ | `gog_sheets_datasource_delete` | Delete a data source, its linked sheet, and unlink dependent objects |
61
65
  | `gog_sheets_named_ranges_list` | List named ranges |
62
66
  | `gog_sheets_named_ranges_get` | Get a named range |
63
67
  | `gog_sheets_named_ranges_add` | Create a named range |
package/dist/index.js CHANGED
@@ -31000,9 +31000,82 @@ var StdioServerTransport = class {
31000
31000
  }
31001
31001
  };
31002
31002
 
31003
+ // ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
31004
+ var McpToolError = class extends Error {
31005
+ /** Actionable remediation text, when one applies. */
31006
+ hint;
31007
+ constructor(message, opts) {
31008
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
31009
+ this.name = "McpToolError";
31010
+ if (opts?.hint !== void 0)
31011
+ this.hint = opts.hint;
31012
+ Object.setPrototypeOf(this, new.target.prototype);
31013
+ }
31014
+ };
31015
+ var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
31016
+ var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
31017
+ var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
31018
+ var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
31019
+ var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
31020
+ var API_KEY_RE = new RegExp([
31021
+ "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
31022
+ // OpenAI / Anthropic (incl. sk-ant-…)
31023
+ "gh[pousr]_[A-Za-z0-9]{36,}\\b",
31024
+ // GitHub ghp_/gho_/ghu_/ghs_/ghr_
31025
+ "xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
31026
+ // Slack
31027
+ "AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
31028
+ // Google API key (39 chars total)
31029
+ "AKIA[0-9A-Z]{16}\\b",
31030
+ // AWS access key id (20 chars total)
31031
+ "whsec_[A-Za-z0-9]{16,}\\b"
31032
+ // webhook signing secret (Stripe-style)
31033
+ ].map((p) => `\\b${p}`).join("|"), "g");
31034
+ var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
31035
+ var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
31036
+ var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
31037
+ var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
31038
+ var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
31039
+ function redactSecrets(text) {
31040
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
31041
+ }
31042
+
31043
+ // ../../node_modules/@chrischall/mcp-utils/dist/response/index.js
31044
+ function rawTextResult(text) {
31045
+ return { content: [{ type: "text", text }] };
31046
+ }
31047
+ function errorResult(message) {
31048
+ return {
31049
+ content: [{ type: "text", text: redactSecrets(message) }],
31050
+ isError: true
31051
+ };
31052
+ }
31053
+
31003
31054
  // ../../node_modules/@chrischall/mcp-utils/dist/server/index.js
31055
+ function hintResultOrRethrow(err) {
31056
+ if (err instanceof McpToolError && err.hint) {
31057
+ return errorResult(`${err.message}
31058
+
31059
+ Hint: ${err.hint}`);
31060
+ }
31061
+ throw err;
31062
+ }
31063
+ function surfaceToolHints(server) {
31064
+ const register = server.registerTool.bind(server);
31065
+ server.registerTool = (name, config2, cb) => register(name, config2, (...args) => {
31066
+ let result;
31067
+ try {
31068
+ result = cb(...args);
31069
+ } catch (err) {
31070
+ return hintResultOrRethrow(err);
31071
+ }
31072
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
31073
+ });
31074
+ }
31004
31075
  async function createMcpServer(opts) {
31005
31076
  const server = new McpServer({ name: opts.name, version: opts.version });
31077
+ if (opts.surfaceHints !== false)
31078
+ surfaceToolHints(server);
31006
31079
  if (opts.banner !== void 0) {
31007
31080
  console.error(opts.banner);
31008
31081
  }
@@ -31047,46 +31120,6 @@ async function runMcp(opts) {
31047
31120
  return server;
31048
31121
  }
31049
31122
 
31050
- // ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
31051
- var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
31052
- var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
31053
- var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
31054
- var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
31055
- var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
31056
- var API_KEY_RE = new RegExp([
31057
- "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
31058
- // OpenAI / Anthropic (incl. sk-ant-…)
31059
- "gh[pousr]_[A-Za-z0-9]{36,}\\b",
31060
- // GitHub ghp_/gho_/ghu_/ghs_/ghr_
31061
- "xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
31062
- // Slack
31063
- "AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
31064
- // Google API key (39 chars total)
31065
- "AKIA[0-9A-Z]{16}\\b",
31066
- // AWS access key id (20 chars total)
31067
- "whsec_[A-Za-z0-9]{16,}\\b"
31068
- // webhook signing secret (Stripe-style)
31069
- ].map((p) => `\\b${p}`).join("|"), "g");
31070
- var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
31071
- var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
31072
- var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
31073
- var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
31074
- var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
31075
- function redactSecrets(text) {
31076
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
31077
- }
31078
-
31079
- // ../../node_modules/@chrischall/mcp-utils/dist/response/index.js
31080
- function rawTextResult(text) {
31081
- return { content: [{ type: "text", text }] };
31082
- }
31083
- function errorResult(message) {
31084
- return {
31085
- content: [{ type: "text", text: redactSecrets(message) }],
31086
- isError: true
31087
- };
31088
- }
31089
-
31090
31123
  // ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
31091
31124
  var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
31092
31125
  function readEnvVar(key, opts = {}) {
@@ -31886,6 +31919,16 @@ function authToolsFor(defaultServices) {
31886
31919
  return (server) => registerAuthToolsWith(server, defaultServices);
31887
31920
  }
31888
31921
 
31922
+ // ../gogcli-mcp/src/tools/calendar.ts
31923
+ var reminderParams = {
31924
+ reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
31925
+ `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.`
31926
+ ),
31927
+ noReminders: external_exports.boolean().optional().describe(
31928
+ "Give the event no reminders at all, overriding the calendar's defaults. Different from an empty reminders array, which RESTORES those defaults. Cannot be combined with reminders."
31929
+ )
31930
+ };
31931
+
31889
31932
  // ../gogcli-mcp/src/attachments.ts
31890
31933
  var MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
31891
31934
  var RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
@@ -31906,6 +31949,28 @@ var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional(
31906
31949
  `Attachments supplied as BYTES rather than as server-side paths \u2014 use this whenever you hold a file and the gog server does not, which is always the case on the hosted connector and on any remote deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). Can be combined with \`attach\`: the two name disjoint files (paths read on the server vs. bytes sent with the call), and both end up as ordinary attachments on the message.`
31907
31950
  );
31908
31951
 
31952
+ // ../gogcli-mcp/src/tools/gmail.ts
31953
+ var replySchema = {
31954
+ messageId: external_exports.string().describe("Gmail message ID to reply to \u2014 the short hex `id` from gog_gmail_get / _search (or gog_gmail_messages_search, gogcli-mcp-gmail only). NOT the threadId, NOT the RFC822 `<\u2026@host>` Message-Id header."),
31955
+ body: external_exports.string().optional().describe("Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body."),
31956
+ bodyHtml: external_exports.string().optional().describe("Reply body (HTML; optional). Pass the HTML itself at any size \u2014 a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile."),
31957
+ bodyHtmlFile: external_exports.string().optional().describe(`Path to an HTML file that ALREADY EXISTS on the gog server for the reply body. gog also accepts "-" for stdin, but this server never writes to gog's stdin, so "-" would hang until the call times out. Mutually exclusive with bodyHtml \u2014 supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.`),
31958
+ to: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message."),
31959
+ cc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Cc (repeatable)"),
31960
+ bcc: external_exports.array(external_exports.string()).optional().describe("Add or move recipients to Bcc (repeatable)"),
31961
+ remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
31962
+ subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
31963
+ noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
31964
+ attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 NOT this client's. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.`),
31965
+ attachInline: attachInlineParam,
31966
+ from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
31967
+ autoFromAddressedAlias: external_exports.boolean().optional().describe("When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account's primary address \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
31968
+ signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
31969
+ signatureFrom: external_exports.string().optional().describe("Append the Gmail signature from this send-as email address"),
31970
+ signatureFile: external_exports.string().optional().describe("Append a local signature file (plain text or HTML), read on the gog server"),
31971
+ account: accountParam
31972
+ };
31973
+
31909
31974
  // ../gogcli-mcp/src/tools/sheets-a1.ts
31910
31975
  function colToLetter(n) {
31911
31976
  let s = "";
@@ -32074,7 +32139,7 @@ function registerSheetsTools(server) {
32074
32139
  }
32075
32140
 
32076
32141
  // ../gogcli-mcp/src/server.ts
32077
- var VERSION = true ? "2.25.0" : "0.0.0";
32142
+ var VERSION = true ? "2.27.0" : "0.0.0";
32078
32143
 
32079
32144
  // ../gogcli-mcp/src/auth-log.ts
32080
32145
  var FAILURES = /* @__PURE__ */ new Set([
@@ -33509,6 +33574,86 @@ ${errorText(err)}`
33509
33574
  if (render) args.push(`--render=${render}`);
33510
33575
  return runOrDiagnose(args, { account });
33511
33576
  });
33577
+ 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.";
33578
+ server.registerTool("gog_sheets_datasource_add", {
33579
+ 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,
33580
+ inputSchema: {
33581
+ spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
33582
+ billingProject: external_exports.string().describe("Billing-enabled BigQuery project charged for this source's queries"),
33583
+ query: external_exports.string().optional().describe("BigQuery SQL to run. Mutually exclusive with dataset/table/tableProject."),
33584
+ dataset: external_exports.string().optional().describe("BigQuery dataset ID of an existing table. Requires table; mutually exclusive with query."),
33585
+ table: external_exports.string().optional().describe("BigQuery table ID. Requires dataset; mutually exclusive with query."),
33586
+ tableProject: external_exports.string().optional().describe("BigQuery project that owns the table (default: the billing project). Only valid with dataset/table."),
33587
+ account: accountParam
33588
+ }
33589
+ }, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
33590
+ const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
33591
+ if (query !== void 0 && tableFlags) {
33592
+ 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.");
33593
+ }
33594
+ if (query === void 0 && !(dataset && table)) {
33595
+ throw new Error("Pass query, or both --dataset and --table (tableProject alone is not a table).");
33596
+ }
33597
+ const args = ["sheets", "datasource", "add", spreadsheetId, `--billing-project=${billingProject}`];
33598
+ if (query !== void 0) args.push(`--query=${query}`);
33599
+ if (tableProject) args.push(`--table-project=${tableProject}`);
33600
+ if (dataset) args.push(`--dataset=${dataset}`);
33601
+ if (table) args.push(`--table=${table}`);
33602
+ return runOrDiagnose(args, { account });
33603
+ });
33604
+ server.registerTool("gog_sheets_datasource_update", {
33605
+ description: "Repoint an existing BigQuery Connected Sheets data source: new SQL for a query-backed source, a new dataset/table for a table-backed one, or a new billing project for either. Only the fields you pass are changed. You cannot convert a source between the two kinds \u2014 gog refuses SQL on a table source and table fields on a query source \u2014 so read gog_sheets_datasource_describe first if you are unsure which kind it is. Replacing the SQL DISCARDS the source's current results." + bigQueryChargeNote + bigQueryScopeNote,
33606
+ annotations: { destructiveHint: true },
33607
+ inputSchema: {
33608
+ spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
33609
+ dataSourceId: external_exports.string().describe("Data source ID, as reported by gog_sheets_datasource_list"),
33610
+ billingProject: external_exports.string().optional().describe("New billing-enabled BigQuery project to charge executions to"),
33611
+ query: external_exports.string().optional().describe("Replacement SQL. Only valid on a query-backed source; mutually exclusive with the table fields."),
33612
+ dataset: external_exports.string().optional().describe("Replacement dataset ID. Only valid on a table-backed source."),
33613
+ table: external_exports.string().optional().describe("Replacement table ID. Only valid on a table-backed source."),
33614
+ tableProject: external_exports.string().optional().describe("Replacement project owning the table. Only valid on a table-backed source."),
33615
+ account: accountParam
33616
+ }
33617
+ }, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
33618
+ const tableFlags = tableProject !== void 0 || dataset !== void 0 || table !== void 0;
33619
+ if (query !== void 0 && tableFlags) {
33620
+ 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.");
33621
+ }
33622
+ if (billingProject === void 0 && query === void 0 && !tableFlags) {
33623
+ throw new Error("nothing to update: pass billingProject, query, dataset, table or tableProject.");
33624
+ }
33625
+ const args = ["sheets", "datasource", "update", spreadsheetId, dataSourceId];
33626
+ if (billingProject !== void 0) args.push(`--billing-project=${billingProject}`);
33627
+ if (query !== void 0) args.push(`--query=${query}`);
33628
+ if (tableProject !== void 0) args.push(`--table-project=${tableProject}`);
33629
+ if (dataset !== void 0) args.push(`--dataset=${dataset}`);
33630
+ if (table !== void 0) args.push(`--table=${table}`);
33631
+ return runOrDiagnose(args, { account });
33632
+ });
33633
+ server.registerTool("gog_sheets_datasource_refresh", {
33634
+ description: "Re-run a Connected Sheets data source so its sheet and extracts pick up current BigQuery data. Google refuses to refresh a source whose previous execution FAILED; forceRefresh overrides that, which is what you want after fixing the underlying query or permissions." + bigQueryChargeNote + bigQueryScopeNote,
33635
+ inputSchema: {
33636
+ spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
33637
+ dataSourceId: external_exports.string().describe("Data source ID, as reported by gog_sheets_datasource_list"),
33638
+ forceRefresh: external_exports.boolean().optional().describe("Refresh even when the previous execution failed"),
33639
+ account: accountParam
33640
+ }
33641
+ }, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
33642
+ const args = ["sheets", "datasource", "refresh", spreadsheetId, dataSourceId];
33643
+ if (forceRefresh) args.push("--force-refresh");
33644
+ return runOrDiagnose(args, { account });
33645
+ });
33646
+ server.registerTool("gog_sheets_datasource_delete", {
33647
+ description: "Delete a Connected Sheets data source. This is WIDER than it sounds: it also deletes the source's linked DATA_SOURCE sheet, and unlinks every extract, chart and pivot table built on it elsewhere in the spreadsheet \u2014 objects on other tabs that the caller never named. Take gog_sheets_snapshot first if any of that matters. The BigQuery data itself is untouched; only the spreadsheet's connection to it goes away." + bigQueryScopeNote,
33648
+ annotations: { destructiveHint: true },
33649
+ inputSchema: {
33650
+ spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
33651
+ dataSourceId: external_exports.string().describe("Data source ID, as reported by gog_sheets_datasource_list"),
33652
+ account: accountParam
33653
+ }
33654
+ }, async ({ spreadsheetId, dataSourceId, account }) => {
33655
+ return runOrDiagnose(["sheets", "datasource", "delete", spreadsheetId, dataSourceId, "--force"], { account });
33656
+ });
33512
33657
  }
33513
33658
 
33514
33659
  // src/index.ts
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": "2.25.0",
6
+ "version": "2.27.0",
7
7
  "description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -339,6 +339,22 @@
339
339
  {
340
340
  "name": "gog_sheets_datasource_table_read",
341
341
  "description": "Read a bounded number of rows out of a data-source table (extract)"
342
+ },
343
+ {
344
+ "name": "gog_sheets_datasource_add",
345
+ "description": "Add a BigQuery Connected Sheets data source (custom SQL or a native table)"
346
+ },
347
+ {
348
+ "name": "gog_sheets_datasource_update",
349
+ "description": "Repoint a Connected Sheets data source's SQL, table or billing project"
350
+ },
351
+ {
352
+ "name": "gog_sheets_datasource_refresh",
353
+ "description": "Re-run a Connected Sheets data source against current BigQuery data"
354
+ },
355
+ {
356
+ "name": "gog_sheets_datasource_delete",
357
+ "description": "Delete a Connected Sheets data source and its linked sheet"
342
358
  }
343
359
  ],
344
360
  "compatibility": {
package/mint.yaml ADDED
@@ -0,0 +1,107 @@
1
+ version: 1
2
+ name: Google Sheets (gogcli)
3
+ slug: gogcli-mcp-sheets
4
+ summary: >-
5
+ Extended Google Sheets MCP server via gogcli — all base tools plus full
6
+ Sheets support
7
+ command:
8
+ # This package publishes a single bin; naming it keeps the install
9
+ # unambiguous alongside its eight sibling packages.
10
+ bin: gogcli-mcp-sheets
11
+ env:
12
+ - name: GOG_CLIENT_ID
13
+ required: false
14
+ help: >-
15
+ Google OAuth client id. NOTE: on the local-spawn path the child `gog`
16
+ receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
17
+ *_TOKEN / *_SECRET / *_API_KEY / *_PRIVATE_KEY variable — so the CLI
18
+ authenticates from its own stored credentials under $HOME (see
19
+ state.dataDir), not from these variables being passed through.
20
+ - name: GOG_CLIENT_SECRET
21
+ secret: true
22
+ required: false
23
+ help: >-
24
+ Google OAuth client secret. Stripped from the spawned CLI's environment
25
+ by runner.ts's *_SECRET rule — see GOG_CLIENT_ID.
26
+ - name: GOG_REFRESH_TOKEN
27
+ secret: true
28
+ required: false
29
+ help: >-
30
+ Google OAuth refresh token. Stripped from the spawned CLI's environment
31
+ by runner.ts's *_TOKEN rule — see GOG_CLIENT_ID. A hosted deployment must
32
+ therefore carry gog's authorised-account state in its persisted data dir,
33
+ or drive a remote runner via GOG_RUNNER_URL.
34
+ - name: GOG_ACCESS_TOKEN
35
+ secret: true
36
+ required: false
37
+ help: >-
38
+ Deliberately removed from the spawned CLI's environment by runner.ts, so
39
+ that a stale directly-passed token cannot shadow gog's stored refresh
40
+ credential. Leave unset.
41
+ - name: GOG_ACCOUNT
42
+ required: false
43
+ help: >-
44
+ Which configured Google account to act as, when more than one is
45
+ authorised. Defaults to the single/most recent account.
46
+ - name: GOG_READONLY
47
+ required: false
48
+ help: >-
49
+ Set to 1 to refuse every mutating operation. Recommended when the
50
+ connector is shared or you only need reads.
51
+ - name: GOG_PATH
52
+ required: false
53
+ help: >-
54
+ Path to the `gog` binary. Leave unset when the dependency below supplies
55
+ it; set it only to point at a binary you manage yourself.
56
+ - name: GOG_RUNNER_URL
57
+ required: false
58
+ help: >-
59
+ URL of a remote gog runner to execute against instead of spawning the
60
+ local binary. If you set this, add its host to egress.allow.
61
+ - name: GOG_RUNNER_KEY
62
+ secret: true
63
+ required: false
64
+ help: >-
65
+ Shared key authenticating calls to GOG_RUNNER_URL. Required whenever that
66
+ is set.
67
+ - name: GOG_TIMEZONE
68
+ required: false
69
+ help: >-
70
+ The IANA zone gog itself formats its naive timestamps in. The wrapper
71
+ reads it (naiveSourceTimeZone) to re-attach the correct offset, so it
72
+ should match gog's own configuration. Falls back to DISPLAY_TZ, then to
73
+ America/New_York.
74
+ - name: DISPLAY_TZ
75
+ required: false
76
+ help: >-
77
+ The IANA zone every rendered *Display field uses (displayTimeZone,
78
+ default America/New_York). It does not read GOG_TIMEZONE — the fallback
79
+ runs the other way, so this is also what GOG_TIMEZONE falls back to. An
80
+ invalid value degrades to the default rather than throwing.
81
+ dependencies:
82
+ # Every tool shells out to the `gog` CLI; without it the server starts and
83
+ # then fails on the first call. This tag must track
84
+ # packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
85
+ # assume) and the fly-gog-runner/Dockerfile GOG_VERSION build arg. See
86
+ # CLAUDE.md "Required gog version" — bumping the floor means bumping these
87
+ # nine pins too.
88
+ - kind: github-release
89
+ repo: openclaw/gogcli
90
+ tag: v0.38.1
91
+ asset: "gogcli_*_linux_amd64.tar.gz"
92
+ bin: [gog]
93
+ state:
94
+ dataDir: true
95
+ reason: >-
96
+ `gog` keeps its authorised-account state and token cache under $HOME.
97
+ Without a persistent data dir every cold start has no account configured and
98
+ every tool call fails until the credentials are re-supplied.
99
+ egress:
100
+ allow:
101
+ # Google OAuth token exchange and the Google REST APIs the CLI calls.
102
+ - oauth2.googleapis.com
103
+ - www.googleapis.com
104
+ - googleapis.com
105
+ #
106
+ # NOTE: if you set GOG_RUNNER_URL to run against a remote gog runner, add
107
+ # that host here too — it is supplied at runtime and cannot be declared.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-sheets",
3
- "version": "2.25.0",
3
+ "version": "2.27.0",
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": "^0.14.0",
27
+ "@chrischall/mcp-utils": "^0.15.0",
28
28
  "@modelcontextprotocol/sdk": "^1.30.0",
29
29
  "zod": "^4.4.3"
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": "2.25.0",
10
+ "version": "2.27.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp-sheets",
15
- "version": "2.25.0",
15
+ "version": "2.27.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -1006,9 +1006,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
1006
1006
  return runOrDiagnose(args, { account });
1007
1007
  });
1008
1008
 
1009
- // Connected Sheets (gog >= 0.37.0, openclaw/gogcli#938). Read-only by
1010
- // construction gog exposes no create/update/refresh/delete here, so none of
1011
- // these can change a data source or trigger a query.
1009
+ // Connected Sheets (gog >= 0.37.0 for the reads, openclaw/gogcli#938; >= 0.38.0
1010
+ // for the writes further down, #1025-#1028). The five tools in this first
1011
+ // group are read-only: none of them changes a data source or starts a query.
1012
1012
  //
1013
1013
  // The scope is the thing to know before calling any of them: Google requires
1014
1014
  // https://www.googleapis.com/auth/bigquery.readonly whenever a Sheets
@@ -1111,4 +1111,135 @@ export function registerExtraSheetsTools(server: McpServer): void {
1111
1111
  return runOrDiagnose(args, { account });
1112
1112
  });
1113
1113
 
1114
+
1115
+ // Connected Sheets WRITES (gog >= 0.38.0, openclaw/gogcli#1025-#1028). The
1116
+ // 0.37.0 half of this surface could only look; these four change a source or
1117
+ // start a query, and they carry two costs the read tools do not:
1118
+ //
1119
+ // 1. Money. Every one of them (delete excepted) starts a BigQuery execution
1120
+ // billed to --billing-project, so a careless refresh of a wide query is a
1121
+ // real charge on someone's account.
1122
+ // 2. Asynchrony. add/update/refresh return a DataExecutionStatus, not data;
1123
+ // the query keeps running after the call returns. gog_sheets_datasource_describe
1124
+ // is how you find out whether it SUCCEEDED or FAILED.
1125
+ //
1126
+ // The BigQuery scope note applies here exactly as it does to the reads: the
1127
+ // mutation is a Sheets write, but the response still carries Connected Sheets
1128
+ // data, so Google demands bigquery.readonly for it too.
1129
+ const bigQueryChargeNote =
1130
+ ' COSTS MONEY AND RUNS ASYNCHRONOUSLY: this starts a BigQuery execution billed to the billing project, and returns ' +
1131
+ 'once the execution has been REQUESTED — not once it has finished. Poll gog_sheets_datasource_describe until its ' +
1132
+ 'dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current.';
1133
+
1134
+ server.registerTool('gog_sheets_datasource_add', {
1135
+ description:
1136
+ 'Add a BigQuery Connected Sheets data source to a spreadsheet, creating its linked DATA_SOURCE sheet. Point it at ' +
1137
+ 'EITHER a custom SQL query (query) OR an existing native table (dataset + table) — never both. billingProject is the ' +
1138
+ 'BigQuery project charged for the query; tableProject only names which project OWNS the table, and defaults to the ' +
1139
+ 'billing project. Returns the new dataSourceId, which every other datasource tool takes.' +
1140
+ bigQueryChargeNote + bigQueryScopeNote,
1141
+ inputSchema: {
1142
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
1143
+ billingProject: z.string().describe('Billing-enabled BigQuery project charged for this source\'s queries'),
1144
+ query: z.string().optional().describe('BigQuery SQL to run. Mutually exclusive with dataset/table/tableProject.'),
1145
+ dataset: z.string().optional().describe('BigQuery dataset ID of an existing table. Requires table; mutually exclusive with query.'),
1146
+ table: z.string().optional().describe('BigQuery table ID. Requires dataset; mutually exclusive with query.'),
1147
+ tableProject: z.string().optional().describe('BigQuery project that owns the table (default: the billing project). Only valid with dataset/table.'),
1148
+ account: accountParam,
1149
+ },
1150
+ }, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
1151
+ const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
1152
+ if (query !== undefined && tableFlags) {
1153
+ throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
1154
+ }
1155
+ if (query === undefined && !(dataset && table)) {
1156
+ throw new Error('Pass query, or both --dataset and --table (tableProject alone is not a table).');
1157
+ }
1158
+ const args = ['sheets', 'datasource', 'add', spreadsheetId, `--billing-project=${billingProject}`];
1159
+ if (query !== undefined) args.push(`--query=${query}`);
1160
+ if (tableProject) args.push(`--table-project=${tableProject}`);
1161
+ if (dataset) args.push(`--dataset=${dataset}`);
1162
+ if (table) args.push(`--table=${table}`);
1163
+ return runOrDiagnose(args, { account });
1164
+ });
1165
+
1166
+ server.registerTool('gog_sheets_datasource_update', {
1167
+ description:
1168
+ 'Repoint an existing BigQuery Connected Sheets data source: new SQL for a query-backed source, a new dataset/table for ' +
1169
+ 'a table-backed one, or a new billing project for either. Only the fields you pass are changed. You cannot convert a ' +
1170
+ 'source between the two kinds — gog refuses SQL on a table source and table fields on a query source — so read ' +
1171
+ 'gog_sheets_datasource_describe first if you are unsure which kind it is. Replacing the SQL DISCARDS the source\'s ' +
1172
+ 'current results.' + bigQueryChargeNote + bigQueryScopeNote,
1173
+ annotations: { destructiveHint: true },
1174
+ inputSchema: {
1175
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
1176
+ dataSourceId: z.string().describe('Data source ID, as reported by gog_sheets_datasource_list'),
1177
+ billingProject: z.string().optional().describe('New billing-enabled BigQuery project to charge executions to'),
1178
+ query: z.string().optional().describe('Replacement SQL. Only valid on a query-backed source; mutually exclusive with the table fields.'),
1179
+ dataset: z.string().optional().describe('Replacement dataset ID. Only valid on a table-backed source.'),
1180
+ table: z.string().optional().describe('Replacement table ID. Only valid on a table-backed source.'),
1181
+ tableProject: z.string().optional().describe('Replacement project owning the table. Only valid on a table-backed source.'),
1182
+ account: accountParam,
1183
+ },
1184
+ }, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
1185
+ const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
1186
+ if (query !== undefined && tableFlags) {
1187
+ throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
1188
+ }
1189
+ if (billingProject === undefined && query === undefined && !tableFlags) {
1190
+ throw new Error('nothing to update: pass billingProject, query, dataset, table or tableProject.');
1191
+ }
1192
+ const args = ['sheets', 'datasource', 'update', spreadsheetId, dataSourceId];
1193
+ if (billingProject !== undefined) args.push(`--billing-project=${billingProject}`);
1194
+ if (query !== undefined) args.push(`--query=${query}`);
1195
+ if (tableProject !== undefined) args.push(`--table-project=${tableProject}`);
1196
+ if (dataset !== undefined) args.push(`--dataset=${dataset}`);
1197
+ if (table !== undefined) args.push(`--table=${table}`);
1198
+ return runOrDiagnose(args, { account });
1199
+ });
1200
+
1201
+ // Refresh is why MIN_GOG_VERSION is 0.38.1 (openclaw/gogcli#1037). On 0.38.0
1202
+ // a batchUpdate that came back with no RefreshDataSource reply produced an
1203
+ // empty status list and exit 0 — this tool would report a clean success for a
1204
+ // billable execution that may or may not have started, and the caller has no
1205
+ // way to tell the two apart. 0.38.1 fails instead, telling the caller to
1206
+ // inspect the source before retrying, which is the behaviour the description
1207
+ // below promises.
1208
+ server.registerTool('gog_sheets_datasource_refresh', {
1209
+ description:
1210
+ 'Re-run a Connected Sheets data source so its sheet and extracts pick up current BigQuery data. Google refuses to ' +
1211
+ 'refresh a source whose previous execution FAILED; forceRefresh overrides that, which is what you want after fixing ' +
1212
+ 'the underlying query or permissions.' + bigQueryChargeNote + bigQueryScopeNote,
1213
+ inputSchema: {
1214
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
1215
+ dataSourceId: z.string().describe('Data source ID, as reported by gog_sheets_datasource_list'),
1216
+ forceRefresh: z.boolean().optional().describe('Refresh even when the previous execution failed'),
1217
+ account: accountParam,
1218
+ },
1219
+ }, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
1220
+ const args = ['sheets', 'datasource', 'refresh', spreadsheetId, dataSourceId];
1221
+ if (forceRefresh) args.push('--force-refresh');
1222
+ return runOrDiagnose(args, { account });
1223
+ });
1224
+
1225
+ server.registerTool('gog_sheets_datasource_delete', {
1226
+ description:
1227
+ 'Delete a Connected Sheets data source. This is WIDER than it sounds: it also deletes the source\'s linked ' +
1228
+ 'DATA_SOURCE sheet, and unlinks every extract, chart and pivot table built on it elsewhere in the spreadsheet — ' +
1229
+ 'objects on other tabs that the caller never named. Take gog_sheets_snapshot first if any of that matters. The ' +
1230
+ 'BigQuery data itself is untouched; only the spreadsheet\'s connection to it goes away.' + bigQueryScopeNote,
1231
+ annotations: { destructiveHint: true },
1232
+ inputSchema: {
1233
+ spreadsheetId: z.string().describe('Spreadsheet ID'),
1234
+ dataSourceId: z.string().describe('Data source ID, as reported by gog_sheets_datasource_list'),
1235
+ account: accountParam,
1236
+ },
1237
+ }, async ({ spreadsheetId, dataSourceId, account }) => {
1238
+ // gog gates this delete behind a confirmation; the runner injects
1239
+ // --no-input, so without --force it refuses at runtime. Verified live
1240
+ // against gog 0.38.0: without --force it answers "refusing to delete
1241
+ // Connected Sheets data source … without --force (non-interactive)".
1242
+ return runOrDiagnose(['sheets', 'datasource', 'delete', spreadsheetId, dataSourceId, '--force'], { account });
1243
+ });
1244
+
1114
1245
  }
@@ -1611,3 +1611,182 @@ describe('gog_sheets_datasource_table_read', () => {
1611
1611
  expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1612
1612
  });
1613
1613
  });
1614
+
1615
+ describe('gog_sheets_datasource_add', () => {
1616
+ it('adds a query-backed source', async () => {
1617
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1618
+ const harness = await setupHandlers();
1619
+ await harness.callTool('gog_sheets_datasource_add', {
1620
+ spreadsheetId: 'sid', billingProject: 'my-proj', query: 'SELECT 1',
1621
+ });
1622
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1623
+ ['sheets', 'datasource', 'add', 'sid', '--billing-project=my-proj', '--query=SELECT 1'],
1624
+ { account: undefined },
1625
+ );
1626
+ });
1627
+
1628
+ it('adds a table-backed source, including the optional table project', async () => {
1629
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1630
+ const harness = await setupHandlers();
1631
+ await harness.callTool('gog_sheets_datasource_add', {
1632
+ spreadsheetId: 'sid', billingProject: 'billing', dataset: 'ds', table: 'tbl', tableProject: 'owner',
1633
+ });
1634
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1635
+ ['sheets', 'datasource', 'add', 'sid', '--billing-project=billing', '--table-project=owner', '--dataset=ds', '--table=tbl'],
1636
+ { account: undefined },
1637
+ );
1638
+ });
1639
+
1640
+ it('refuses a call that mixes a query with table flags before spawning gog', async () => {
1641
+ const harness = await setupHandlers();
1642
+ const result = await harness.callTool('gog_sheets_datasource_add', {
1643
+ spreadsheetId: 'sid', billingProject: 'p', query: 'SELECT 1', dataset: 'ds', table: 'tbl',
1644
+ });
1645
+ expect(result.isError).toBe(true);
1646
+ expect(result.content[0].text).toContain('mutually exclusive');
1647
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1648
+ });
1649
+
1650
+ it('refuses a call that names neither a query nor a complete table', async () => {
1651
+ const harness = await setupHandlers();
1652
+ const result = await harness.callTool('gog_sheets_datasource_add', {
1653
+ spreadsheetId: 'sid', billingProject: 'p', dataset: 'ds',
1654
+ });
1655
+ expect(result.isError).toBe(true);
1656
+ expect(result.content[0].text).toContain('--dataset and --table');
1657
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1658
+ });
1659
+ });
1660
+
1661
+ describe('gog_sheets_datasource_update', () => {
1662
+ it('sends only the fields the caller supplied', async () => {
1663
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1664
+ const harness = await setupHandlers();
1665
+ await harness.callTool('gog_sheets_datasource_update', {
1666
+ spreadsheetId: 'sid', dataSourceId: 'ds1', query: 'SELECT 2',
1667
+ });
1668
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1669
+ ['sheets', 'datasource', 'update', 'sid', 'ds1', '--query=SELECT 2'],
1670
+ { account: undefined },
1671
+ );
1672
+ });
1673
+
1674
+ it('updates the billing project and table fields together', async () => {
1675
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1676
+ const harness = await setupHandlers();
1677
+ await harness.callTool('gog_sheets_datasource_update', {
1678
+ spreadsheetId: 'sid', dataSourceId: 'ds1', billingProject: 'new-proj', dataset: 'ds2', table: 't2',
1679
+ });
1680
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1681
+ ['sheets', 'datasource', 'update', 'sid', 'ds1', '--billing-project=new-proj', '--dataset=ds2', '--table=t2'],
1682
+ { account: undefined },
1683
+ );
1684
+ });
1685
+
1686
+ it('moves a table-backed source to another owning project', async () => {
1687
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1688
+ const harness = await setupHandlers();
1689
+ await harness.callTool('gog_sheets_datasource_update', {
1690
+ spreadsheetId: 'sid', dataSourceId: 'ds1', tableProject: 'other-owner',
1691
+ });
1692
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1693
+ ['sheets', 'datasource', 'update', 'sid', 'ds1', '--table-project=other-owner'],
1694
+ { account: undefined },
1695
+ );
1696
+ });
1697
+
1698
+ it('refuses a call that mixes a query with table flags', async () => {
1699
+ const harness = await setupHandlers();
1700
+ const result = await harness.callTool('gog_sheets_datasource_update', {
1701
+ spreadsheetId: 'sid', dataSourceId: 'ds1', query: 'SELECT 2', table: 't2',
1702
+ });
1703
+ expect(result.isError).toBe(true);
1704
+ expect(result.content[0].text).toContain('mutually exclusive');
1705
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1706
+ });
1707
+
1708
+ it('refuses a call with nothing to update', async () => {
1709
+ const harness = await setupHandlers();
1710
+ const result = await harness.callTool('gog_sheets_datasource_update', { spreadsheetId: 'sid', dataSourceId: 'ds1' });
1711
+ expect(result.isError).toBe(true);
1712
+ expect(result.content[0].text).toContain('nothing to update');
1713
+ expect(lib.runOrDiagnose).not.toHaveBeenCalled();
1714
+ });
1715
+ });
1716
+
1717
+ describe('gog_sheets_datasource_refresh', () => {
1718
+ it('requests a refresh', async () => {
1719
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1720
+ const harness = await setupHandlers();
1721
+ await harness.callTool('gog_sheets_datasource_refresh', { spreadsheetId: 'sid', dataSourceId: 'ds1' });
1722
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1723
+ ['sheets', 'datasource', 'refresh', 'sid', 'ds1'],
1724
+ { account: undefined },
1725
+ );
1726
+ });
1727
+
1728
+ it('passes --force-refresh after a failed execution', async () => {
1729
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1730
+ const harness = await setupHandlers();
1731
+ await harness.callTool('gog_sheets_datasource_refresh', {
1732
+ spreadsheetId: 'sid', dataSourceId: 'ds1', forceRefresh: true,
1733
+ });
1734
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1735
+ ['sheets', 'datasource', 'refresh', 'sid', 'ds1', '--force-refresh'],
1736
+ { account: undefined },
1737
+ );
1738
+ });
1739
+ });
1740
+
1741
+ describe('gog_sheets_datasource_delete', () => {
1742
+ // Every tool a description NAMES has to exist in this package, and a name
1743
+ // that does not is worse than no advice: the model calls it, gets "unknown
1744
+ // tool", and may skip the step the description was insisting on — here, the
1745
+ // backup before an unlink-everything delete. gog_sheets_backup was such a
1746
+ // name; the snapshot tool is gog_sheets_snapshot. Checked across the WHOLE
1747
+ // package (index.ts composes auth + base sheets + these extras), because a
1748
+ // description may legitimately point at a tool another registrar supplies.
1749
+ it('names only tools this package actually registers', async () => {
1750
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
1751
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
1752
+ const configs = new Map<string, { description?: string }>();
1753
+ vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
1754
+ configs.set(name, config as { description?: string });
1755
+ return undefined as never;
1756
+ });
1757
+ lib.authToolsFor('sheets')(server);
1758
+ lib.registerSheetsTools(server);
1759
+ registerExtraSheetsTools(server);
1760
+ const names = [...configs.keys()];
1761
+ for (const [tool, config] of configs) {
1762
+ for (const [referenced] of (config.description ?? '').matchAll(/gog_[a-z_0-9]+/g)) {
1763
+ expect(names, `${tool} references ${referenced}`).toContain(referenced);
1764
+ }
1765
+ }
1766
+ });
1767
+
1768
+ // gog gates this behind a confirmation and the runner always injects
1769
+ // --no-input, so a missing --force fails at runtime against a real gog while
1770
+ // every mocked test still passes. Confirmed live on gog 0.38.0.
1771
+ it('appends --force so the confirmation gate does not refuse it', async () => {
1772
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1773
+ const harness = await setupHandlers();
1774
+ await harness.callTool('gog_sheets_datasource_delete', { spreadsheetId: 'sid', dataSourceId: 'ds1' });
1775
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1776
+ ['sheets', 'datasource', 'delete', 'sid', 'ds1', '--force'],
1777
+ { account: undefined },
1778
+ );
1779
+ });
1780
+
1781
+ it('passes the account through', async () => {
1782
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
1783
+ const harness = await setupHandlers();
1784
+ await harness.callTool('gog_sheets_datasource_delete', {
1785
+ spreadsheetId: 'sid', dataSourceId: 'ds1', account: 'me@x.com',
1786
+ });
1787
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
1788
+ ['sheets', 'datasource', 'delete', 'sid', 'ds1', '--force'],
1789
+ { account: 'me@x.com' },
1790
+ );
1791
+ });
1792
+ });