gogcli-mcp 2.0.12 → 2.2.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": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
10
- "version": "2.0.12"
10
+ "version": "2.2.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "gogcli",
16
16
  "source": "./",
17
17
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
18
- "version": "2.0.12",
18
+ "version": "2.2.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
3
  "displayName": "gogcli",
4
- "version": "2.0.12",
4
+ "version": "2.2.0",
5
5
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## [2.1.0](https://github.com/chrischall/gogcli-mcp/compare/v2.0.13...v2.1.0) (2026-05-25)
4
+
5
+
6
+ ### Features
7
+
8
+ * add gogcli-mcp-slides and gogcli-mcp-classroom sub-packages ([cee8724](https://github.com/chrischall/gogcli-mcp/commit/cee872442240e52ae1cac9b9085eabce0c5ff9c3))
9
+ * add manifest.json and SKILL.md to sub-packages for .mcpb and .skill builds ([d1339c6](https://github.com/chrischall/gogcli-mcp/commit/d1339c690b1eeee8221b7cc15fd9e4fa48c1e20a))
10
+ * address open issues for docs + sheets ([7109253](https://github.com/chrischall/gogcli-mcp/commit/7109253b3eb4b3dcc17cda7a8dfb1b6969023bcf))
11
+ * **deploy:** per-sub-package registry listings (MCP Registry, ClawHub, Claude plugins) ([6cc1798](https://github.com/chrischall/gogcli-mcp/commit/6cc1798320af6dd3af709790e6bec30f46c5f3d2))
12
+ * **drive:** add gogcli-mcp-drive sub-package ([d8eef61](https://github.com/chrischall/gogcli-mcp/commit/d8eef61f16266209da70cd05ca0d2378bf3c4613))
13
+ * fold People into contacts and Meet into calendar; add TODO.md ([f649119](https://github.com/chrischall/gogcli-mcp/commit/f64911902094f87ae31420913e9d1a5ba608441f))
14
+ * wrap new gog cli v0.18.0 tools ([173242a](https://github.com/chrischall/gogcli-mcp/commit/173242aafaa8c2f2d7282a2fbd8faedfc65a63bd))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * emit type declarations from base so sub-packages can resolve gogcli-mcp/lib ([9cee5ed](https://github.com/chrischall/gogcli-mcp/commit/9cee5ed97b0cc72dde81c7f8a01d71c95a0291b7))
20
+ * **mcpb:** add per-sub-package .mcpbignore to trim bundles ([e739d9e](https://github.com/chrischall/gogcli-mcp/commit/e739d9ec9ae8e4cc19b1189bb5bf7ac90481c943))
21
+ * **runner:** augment PATH + helpful ENOENT message for missing gog ([34e696f](https://github.com/chrischall/gogcli-mcp/commit/34e696f8c74c7003e6b1bb31991a05184a3b6094))
22
+ * **runner:** fall back to PATH lookup when GOG_PATH is empty string ([a726592](https://github.com/chrischall/gogcli-mcp/commit/a726592c86e869fe27b176798a9b8909e7821f4c))
23
+ * **runner:** treat unresolved .mcpb placeholders as unset env vars ([17a0363](https://github.com/chrischall/gogcli-mcp/commit/17a0363f7dffd2757667e0d1d9377e2e2cb6fbc7))
24
+ * treat empty GOG_PATH as unset, fall back to 'gog' on PATH ([0204638](https://github.com/chrischall/gogcli-mcp/commit/020463813f941b1fa8ec2360346c55abc244675f))
25
+
26
+
27
+ ### Refactor
28
+
29
+ * aggressive cleanup pass (audit findings) ([e635b39](https://github.com/chrischall/gogcli-mcp/commit/e635b391cf611e9c102a422ffbce5ebe28687b80))
30
+ * make sub-packages focused (auth + service only) ([4301306](https://github.com/chrischall/gogcli-mcp/commit/4301306cecf12e66d67dbc4ce9debae8f3dcfabc))
31
+ * rebalance base vs sub-package tool split (120 -> 84 base) ([1074953](https://github.com/chrischall/gogcli-mcp/commit/1074953833fc4766e85cc0b28cbcc08834f8dc47))
32
+ * remove comments escape hatch from base docs tool ([a5364c6](https://github.com/chrischall/gogcli-mcp/commit/a5364c6df1948e57b185b9d753aa11b033edd0d0))
33
+ * restructure into npm workspaces monorepo ([9645100](https://github.com/chrischall/gogcli-mcp/commit/9645100e8120c03956ee972023b12d2e99876cc5))
34
+ * review-pass cleanup (enums, shared schemas, test harness) ([ea851de](https://github.com/chrischall/gogcli-mcp/commit/ea851deb30b9cfef3f07ed506a011fc363b34018))
35
+ * use relative imports instead of workspace symlinks ([a278d6f](https://github.com/chrischall/gogcli-mcp/commit/a278d6fb31be480571481e9d87e199d261a3f031))
36
+
37
+
38
+ ### Documentation
39
+
40
+ * add per-package READMEs, update root README and CLAUDE.md for monorepo ([19d547e](https://github.com/chrischall/gogcli-mcp/commit/19d547e5ceb14298a75ab297d0a9a11c6ea59e24))
41
+ * fix tool counts and stale descriptions across all docs ([3558c72](https://github.com/chrischall/gogcli-mcp/commit/3558c72fa5c3ee3dbc8a06e9d7fb51bf73ef77c7))
42
+ * update gogcli repo URLs to openclaw/gogcli (was steipete/gogcli) ([951a181](https://github.com/chrischall/gogcli-mcp/commit/951a1818ebc269f203aee723ccbea32c13817d8b))
package/dist/index.js CHANGED
@@ -31074,7 +31074,7 @@ async function run(args, options = {}) {
31074
31074
 
31075
31075
  // src/tools/utils.ts
31076
31076
  var accountParam = external_exports.string().optional().describe(
31077
- "Google account email to use (overrides GOG_ACCOUNT env var)"
31077
+ "Google account email to use, e.g. you@gmail.com \u2014 must be the full address, not a bare username. Overrides the GOG_ACCOUNT env var. Omit to use the single configured account."
31078
31078
  );
31079
31079
  var ids = {
31080
31080
  course: external_exports.string().describe("Course ID"),
@@ -31133,26 +31133,41 @@ function toError(err) {
31133
31133
  }
31134
31134
  var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
31135
31135
  var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
31136
+ var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
31136
31137
  var AUTH_HINT = "\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. Ask the user if they would like to re-authenticate.";
31137
31138
  var TRANSIENT_HINT = "\n\nThis error is often transient. Retry the same call before trying a different approach (do not fall back to smaller writes or row-by-row operations).";
31139
+ var GRID_LIMIT_HINT = "\n\nThe target range is outside the sheet's current grid. Add the missing rows or columns first with gog_sheets_insert (dimension: rows or cols), then retry the write.";
31140
+ function formatAccountList(raw) {
31141
+ try {
31142
+ const parsed = JSON.parse(raw);
31143
+ if (Array.isArray(parsed?.accounts)) {
31144
+ return parsed.accounts.map((a) => a?.email).filter(Boolean).join("\n");
31145
+ }
31146
+ } catch {
31147
+ }
31148
+ return raw.trim();
31149
+ }
31150
+ async function diagnose(err) {
31151
+ const errText = toError(err).content[0].text;
31152
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31153
+ const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31154
+ const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
31155
+ const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
31156
+ try {
31157
+ const accounts = formatAccountList(await run(["auth", "list"]));
31158
+ return toText(`${errText}
31159
+
31160
+ Configured accounts:
31161
+ ${accounts || "(none)"}${hint}`);
31162
+ } catch {
31163
+ return toText(`${errText}${hint}`);
31164
+ }
31165
+ }
31138
31166
  async function runOrDiagnose(args, options) {
31139
31167
  try {
31140
31168
  return toText(await run(args, options));
31141
31169
  } catch (err) {
31142
- const base = toError(err);
31143
- const errText = base.content[0].text;
31144
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31145
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31146
- const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : "";
31147
- try {
31148
- const accounts = await run(["auth", "list"]);
31149
- return toText(`${errText}
31150
-
31151
- Configured accounts:
31152
- ${accounts}${hint}`);
31153
- } catch {
31154
- return toText(`${errText}${hint}`);
31155
- }
31170
+ return diagnose(err);
31156
31171
  }
31157
31172
  }
31158
31173
 
@@ -31941,7 +31956,7 @@ function registerDriveTools(server2) {
31941
31956
  // src/tools/gmail.ts
31942
31957
  function registerGmailTools(server2) {
31943
31958
  server2.registerTool("gog_gmail_search", {
31944
- description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread").',
31959
+ description: `Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail's own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).`,
31945
31960
  annotations: { readOnlyHint: true },
31946
31961
  inputSchema: {
31947
31962
  query: external_exports.string().describe("Gmail search query"),
@@ -31990,8 +32005,62 @@ function registerGmailTools(server2) {
31990
32005
  registerRunTool(server2, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
31991
32006
  }
31992
32007
 
32008
+ // src/tools/sheets-a1.ts
32009
+ function colToLetter(n) {
32010
+ let s = "";
32011
+ while (n > 0) {
32012
+ const rem = (n - 1) % 26;
32013
+ s = String.fromCharCode(65 + rem) + s;
32014
+ n = Math.floor((n - 1) / 26);
32015
+ }
32016
+ return s;
32017
+ }
32018
+ function letterToCol(s) {
32019
+ let n = 0;
32020
+ for (const ch of s.toUpperCase()) {
32021
+ n = n * 26 + (ch.charCodeAt(0) - 64);
32022
+ }
32023
+ return n;
32024
+ }
32025
+ function expandAnchorRange(range, rows, cols) {
32026
+ const bang = range.lastIndexOf("!");
32027
+ const sheet = bang >= 0 ? range.slice(0, bang + 1) : "";
32028
+ const cell = bang >= 0 ? range.slice(bang + 1) : range;
32029
+ const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
32030
+ if (!m) return range;
32031
+ const startCol = letterToCol(m[1]);
32032
+ const startRow = parseInt(m[2], 10);
32033
+ const endCol = colToLetter(startCol + cols - 1);
32034
+ const endRow = startRow + rows - 1;
32035
+ return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
32036
+ }
32037
+ function countNonEmptyCells(getOutput) {
32038
+ let parsed;
32039
+ try {
32040
+ parsed = JSON.parse(getOutput);
32041
+ } catch {
32042
+ return -1;
32043
+ }
32044
+ const values = parsed?.values;
32045
+ if (!Array.isArray(values)) return 0;
32046
+ let count = 0;
32047
+ for (const row of values) {
32048
+ if (!Array.isArray(row)) continue;
32049
+ for (const cell of row) {
32050
+ if (cell !== null && String(cell).trim() !== "") count++;
32051
+ }
32052
+ }
32053
+ return count;
32054
+ }
32055
+
31993
32056
  // src/tools/sheets.ts
31994
32057
  var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
32058
+ var dryRunParam = external_exports.boolean().optional().describe(
32059
+ "Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
32060
+ );
32061
+ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
32062
+ 'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.'
32063
+ );
31995
32064
  function registerSheetsTools(server2) {
31996
32065
  server2.registerTool("gog_sheets_get", {
31997
32066
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -32011,13 +32080,31 @@ function registerSheetsTools(server2) {
32011
32080
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
32012
32081
  range: external_exports.string().describe("Top-left cell or range in A1 notation, e.g. Sheet1!A1"),
32013
32082
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
32083
+ dry_run: dryRunParam,
32084
+ fail_if_not_empty: failIfNotEmptyParam,
32014
32085
  account: accountParam
32015
32086
  }
32016
- }, async ({ spreadsheetId, range, values, account }) => {
32017
- return runOrDiagnose(
32018
- ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
32019
- { account }
32020
- );
32087
+ }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
32088
+ const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
32089
+ if (fail_if_not_empty && values.length > 0 && cols > 0) {
32090
+ const readRange = expandAnchorRange(range, values.length, cols);
32091
+ let existing;
32092
+ try {
32093
+ existing = await run(["sheets", "get", spreadsheetId, readRange], { account });
32094
+ } catch (err) {
32095
+ return diagnose(err);
32096
+ }
32097
+ const occupied = countNonEmptyCells(existing);
32098
+ if (occupied !== 0) {
32099
+ const detail = occupied < 0 ? "could not be verified as empty" : `already contains data in ${occupied} cell(s)`;
32100
+ return toText(
32101
+ `Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.`
32102
+ );
32103
+ }
32104
+ }
32105
+ const args = ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
32106
+ if (dry_run) args.push("--dry-run");
32107
+ return runOrDiagnose(args, { account });
32021
32108
  });
32022
32109
  server2.registerTool("gog_sheets_append", {
32023
32110
  description: 'Append rows to a Google Sheet after the last row with data in the given range. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas.',
@@ -32026,13 +32113,13 @@ function registerSheetsTools(server2) {
32026
32113
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
32027
32114
  range: external_exports.string().describe("Range indicating which sheet/columns to append to, e.g. Sheet1!A:C"),
32028
32115
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
32116
+ dry_run: dryRunParam,
32029
32117
  account: accountParam
32030
32118
  }
32031
- }, async ({ spreadsheetId, range, values, account }) => {
32032
- return runOrDiagnose(
32033
- ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
32034
- { account }
32035
- );
32119
+ }, async ({ spreadsheetId, range, values, account, dry_run }) => {
32120
+ const args = ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
32121
+ if (dry_run) args.push("--dry-run");
32122
+ return runOrDiagnose(args, { account });
32036
32123
  });
32037
32124
  server2.registerTool("gog_sheets_clear", {
32038
32125
  description: "Clear all values in a Google Sheets range (formatting is preserved).",
@@ -32040,13 +32127,16 @@ function registerSheetsTools(server2) {
32040
32127
  inputSchema: {
32041
32128
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
32042
32129
  range: external_exports.string().describe("Range in A1 notation to clear"),
32130
+ dry_run: dryRunParam,
32043
32131
  account: accountParam
32044
32132
  }
32045
- }, async ({ spreadsheetId, range, account }) => {
32046
- return runOrDiagnose(["sheets", "clear", spreadsheetId, range], { account });
32133
+ }, async ({ spreadsheetId, range, account, dry_run }) => {
32134
+ const args = ["sheets", "clear", spreadsheetId, range];
32135
+ if (dry_run) args.push("--dry-run");
32136
+ return runOrDiagnose(args, { account });
32047
32137
  });
32048
32138
  server2.registerTool("gog_sheets_metadata", {
32049
- description: "Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.",
32139
+ description: "Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet's current size before writing \u2014 a write outside the grid fails.",
32050
32140
  annotations: { readOnlyHint: true },
32051
32141
  inputSchema: {
32052
32142
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
@@ -32232,7 +32322,7 @@ function registerTasksTools(server2) {
32232
32322
  }
32233
32323
 
32234
32324
  // src/server.ts
32235
- var VERSION = true ? "2.0.12" : "0.0.0";
32325
+ var VERSION = true ? "2.2.0" : "0.0.0";
32236
32326
  function createServer(options) {
32237
32327
  return new McpServer({
32238
32328
  name: options?.name ?? "gogcli",
package/dist/lib.js CHANGED
@@ -30981,7 +30981,7 @@ async function run(args, options = {}) {
30981
30981
 
30982
30982
  // src/tools/utils.ts
30983
30983
  var accountParam = external_exports.string().optional().describe(
30984
- "Google account email to use (overrides GOG_ACCOUNT env var)"
30984
+ "Google account email to use, e.g. you@gmail.com \u2014 must be the full address, not a bare username. Overrides the GOG_ACCOUNT env var. Omit to use the single configured account."
30985
30985
  );
30986
30986
  var ids = {
30987
30987
  course: external_exports.string().describe("Course ID"),
@@ -31045,26 +31045,41 @@ function toError(err) {
31045
31045
  }
31046
31046
  var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
31047
31047
  var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
31048
+ var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
31048
31049
  var AUTH_HINT = "\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. Ask the user if they would like to re-authenticate.";
31049
31050
  var TRANSIENT_HINT = "\n\nThis error is often transient. Retry the same call before trying a different approach (do not fall back to smaller writes or row-by-row operations).";
31051
+ var GRID_LIMIT_HINT = "\n\nThe target range is outside the sheet's current grid. Add the missing rows or columns first with gog_sheets_insert (dimension: rows or cols), then retry the write.";
31052
+ function formatAccountList(raw) {
31053
+ try {
31054
+ const parsed = JSON.parse(raw);
31055
+ if (Array.isArray(parsed?.accounts)) {
31056
+ return parsed.accounts.map((a) => a?.email).filter(Boolean).join("\n");
31057
+ }
31058
+ } catch {
31059
+ }
31060
+ return raw.trim();
31061
+ }
31062
+ async function diagnose(err) {
31063
+ const errText = toError(err).content[0].text;
31064
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31065
+ const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31066
+ const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
31067
+ const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
31068
+ try {
31069
+ const accounts = formatAccountList(await run(["auth", "list"]));
31070
+ return toText(`${errText}
31071
+
31072
+ Configured accounts:
31073
+ ${accounts || "(none)"}${hint}`);
31074
+ } catch {
31075
+ return toText(`${errText}${hint}`);
31076
+ }
31077
+ }
31050
31078
  async function runOrDiagnose(args, options) {
31051
31079
  try {
31052
31080
  return toText(await run(args, options));
31053
31081
  } catch (err) {
31054
- const base = toError(err);
31055
- const errText = base.content[0].text;
31056
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
31057
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
31058
- const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : "";
31059
- try {
31060
- const accounts = await run(["auth", "list"]);
31061
- return toText(`${errText}
31062
-
31063
- Configured accounts:
31064
- ${accounts}${hint}`);
31065
- } catch {
31066
- return toText(`${errText}${hint}`);
31067
- }
31082
+ return diagnose(err);
31068
31083
  }
31069
31084
  }
31070
31085
 
@@ -31853,7 +31868,7 @@ function registerDriveTools(server) {
31853
31868
  // src/tools/gmail.ts
31854
31869
  function registerGmailTools(server) {
31855
31870
  server.registerTool("gog_gmail_search", {
31856
- description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread").',
31871
+ description: `Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail's own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).`,
31857
31872
  annotations: { readOnlyHint: true },
31858
31873
  inputSchema: {
31859
31874
  query: external_exports.string().describe("Gmail search query"),
@@ -31902,8 +31917,62 @@ function registerGmailTools(server) {
31902
31917
  registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
31903
31918
  }
31904
31919
 
31920
+ // src/tools/sheets-a1.ts
31921
+ function colToLetter(n) {
31922
+ let s = "";
31923
+ while (n > 0) {
31924
+ const rem = (n - 1) % 26;
31925
+ s = String.fromCharCode(65 + rem) + s;
31926
+ n = Math.floor((n - 1) / 26);
31927
+ }
31928
+ return s;
31929
+ }
31930
+ function letterToCol(s) {
31931
+ let n = 0;
31932
+ for (const ch of s.toUpperCase()) {
31933
+ n = n * 26 + (ch.charCodeAt(0) - 64);
31934
+ }
31935
+ return n;
31936
+ }
31937
+ function expandAnchorRange(range, rows, cols) {
31938
+ const bang = range.lastIndexOf("!");
31939
+ const sheet = bang >= 0 ? range.slice(0, bang + 1) : "";
31940
+ const cell = bang >= 0 ? range.slice(bang + 1) : range;
31941
+ const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
31942
+ if (!m) return range;
31943
+ const startCol = letterToCol(m[1]);
31944
+ const startRow = parseInt(m[2], 10);
31945
+ const endCol = colToLetter(startCol + cols - 1);
31946
+ const endRow = startRow + rows - 1;
31947
+ return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
31948
+ }
31949
+ function countNonEmptyCells(getOutput) {
31950
+ let parsed;
31951
+ try {
31952
+ parsed = JSON.parse(getOutput);
31953
+ } catch {
31954
+ return -1;
31955
+ }
31956
+ const values = parsed?.values;
31957
+ if (!Array.isArray(values)) return 0;
31958
+ let count = 0;
31959
+ for (const row of values) {
31960
+ if (!Array.isArray(row)) continue;
31961
+ for (const cell of row) {
31962
+ if (cell !== null && String(cell).trim() !== "") count++;
31963
+ }
31964
+ }
31965
+ return count;
31966
+ }
31967
+
31905
31968
  // src/tools/sheets.ts
31906
31969
  var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
31970
+ var dryRunParam = external_exports.boolean().optional().describe(
31971
+ "Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
31972
+ );
31973
+ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
31974
+ 'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.'
31975
+ );
31907
31976
  function registerSheetsTools(server) {
31908
31977
  server.registerTool("gog_sheets_get", {
31909
31978
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -31923,13 +31992,31 @@ function registerSheetsTools(server) {
31923
31992
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31924
31993
  range: external_exports.string().describe("Top-left cell or range in A1 notation, e.g. Sheet1!A1"),
31925
31994
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
31995
+ dry_run: dryRunParam,
31996
+ fail_if_not_empty: failIfNotEmptyParam,
31926
31997
  account: accountParam
31927
31998
  }
31928
- }, async ({ spreadsheetId, range, values, account }) => {
31929
- return runOrDiagnose(
31930
- ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
31931
- { account }
31932
- );
31999
+ }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
32000
+ const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
32001
+ if (fail_if_not_empty && values.length > 0 && cols > 0) {
32002
+ const readRange = expandAnchorRange(range, values.length, cols);
32003
+ let existing;
32004
+ try {
32005
+ existing = await run(["sheets", "get", spreadsheetId, readRange], { account });
32006
+ } catch (err) {
32007
+ return diagnose(err);
32008
+ }
32009
+ const occupied = countNonEmptyCells(existing);
32010
+ if (occupied !== 0) {
32011
+ const detail = occupied < 0 ? "could not be verified as empty" : `already contains data in ${occupied} cell(s)`;
32012
+ return toText(
32013
+ `Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.`
32014
+ );
32015
+ }
32016
+ }
32017
+ const args = ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
32018
+ if (dry_run) args.push("--dry-run");
32019
+ return runOrDiagnose(args, { account });
31933
32020
  });
31934
32021
  server.registerTool("gog_sheets_append", {
31935
32022
  description: 'Append rows to a Google Sheet after the last row with data in the given range. Values may be strings, numbers, booleans, or null. Strings starting with "=" are interpreted as formulas.',
@@ -31938,13 +32025,13 @@ function registerSheetsTools(server) {
31938
32025
  spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
31939
32026
  range: external_exports.string().describe("Range indicating which sheet/columns to append to, e.g. Sheet1!A:C"),
31940
32027
  values: external_exports.array(external_exports.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
32028
+ dry_run: dryRunParam,
31941
32029
  account: accountParam
31942
32030
  }
31943
- }, async ({ spreadsheetId, range, values, account }) => {
31944
- return runOrDiagnose(
31945
- ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
31946
- { account }
31947
- );
32031
+ }, async ({ spreadsheetId, range, values, account, dry_run }) => {
32032
+ const args = ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
32033
+ if (dry_run) args.push("--dry-run");
32034
+ return runOrDiagnose(args, { account });
31948
32035
  });
31949
32036
  server.registerTool("gog_sheets_clear", {
31950
32037
  description: "Clear all values in a Google Sheets range (formatting is preserved).",
@@ -31952,13 +32039,16 @@ function registerSheetsTools(server) {
31952
32039
  inputSchema: {
31953
32040
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
31954
32041
  range: external_exports.string().describe("Range in A1 notation to clear"),
32042
+ dry_run: dryRunParam,
31955
32043
  account: accountParam
31956
32044
  }
31957
- }, async ({ spreadsheetId, range, account }) => {
31958
- return runOrDiagnose(["sheets", "clear", spreadsheetId, range], { account });
32045
+ }, async ({ spreadsheetId, range, account, dry_run }) => {
32046
+ const args = ["sheets", "clear", spreadsheetId, range];
32047
+ if (dry_run) args.push("--dry-run");
32048
+ return runOrDiagnose(args, { account });
31959
32049
  });
31960
32050
  server.registerTool("gog_sheets_metadata", {
31961
- description: "Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.",
32051
+ description: "Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet's current size before writing \u2014 a write outside the grid fails.",
31962
32052
  annotations: { readOnlyHint: true },
31963
32053
  inputSchema: {
31964
32054
  spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
@@ -32144,7 +32234,7 @@ function registerTasksTools(server) {
32144
32234
  }
32145
32235
 
32146
32236
  // src/server.ts
32147
- var VERSION = true ? "2.0.12" : "0.0.0";
32237
+ var VERSION = true ? "2.2.0" : "0.0.0";
32148
32238
  function createServer(options) {
32149
32239
  return new McpServer({
32150
32240
  name: options?.name ?? "gogcli",
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "2.0.12",
6
+ "version": "2.2.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.0.12",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -45,8 +45,8 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@types/node": "^25.6.2",
49
- "@vitest/coverage-v8": "^4.1.6",
48
+ "@types/node": "^25.9.1",
49
+ "@vitest/coverage-v8": "^4.1.7",
50
50
  "esbuild": "^0.28.0",
51
51
  "typescript": "^6.0.2",
52
52
  "vitest": "^4.1.6"
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.0.12",
10
+ "version": "2.2.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.0.12",
15
+ "version": "2.2.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -4,7 +4,7 @@ import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
5
5
  export function registerGmailTools(server: McpServer): void {
6
6
  server.registerTool('gog_gmail_search', {
7
- description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread").',
7
+ description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com).',
8
8
  annotations: { readOnlyHint: true },
9
9
  inputSchema: {
10
10
  query: z.string().describe('Gmail search query'),
@@ -0,0 +1,64 @@
1
+ // A1-notation helpers for the fail_if_not_empty write guard. Kept separate
2
+ // from sheets.ts so the pure range/value math is unit-testable in isolation.
3
+
4
+ // 1 -> "A", 26 -> "Z", 27 -> "AA" (bijective base-26).
5
+ function colToLetter(n: number): string {
6
+ let s = '';
7
+ while (n > 0) {
8
+ const rem = (n - 1) % 26;
9
+ s = String.fromCharCode(65 + rem) + s;
10
+ n = Math.floor((n - 1) / 26);
11
+ }
12
+ return s;
13
+ }
14
+
15
+ // "A" -> 1, "AA" -> 27.
16
+ function letterToCol(s: string): number {
17
+ let n = 0;
18
+ for (const ch of s.toUpperCase()) {
19
+ n = n * 26 + (ch.charCodeAt(0) - 64);
20
+ }
21
+ return n;
22
+ }
23
+
24
+ // Given the target `range` of an update and the shape of the values being
25
+ // written, return the range the guard should read to cover every written cell.
26
+ //
27
+ // A bare anchor cell ("Sheet1!A1") is expanded to the full written area
28
+ // ("Sheet1!A1:D70"). An explicit range (contains ":") or a named range is
29
+ // returned unchanged — the caller asked us to check exactly that span.
30
+ export function expandAnchorRange(range: string, rows: number, cols: number): string {
31
+ const bang = range.lastIndexOf('!');
32
+ const sheet = bang >= 0 ? range.slice(0, bang + 1) : '';
33
+ const cell = bang >= 0 ? range.slice(bang + 1) : range;
34
+ const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
35
+ if (!m) return range;
36
+ const startCol = letterToCol(m[1]);
37
+ const startRow = parseInt(m[2], 10);
38
+ const endCol = colToLetter(startCol + cols - 1);
39
+ const endRow = startRow + rows - 1;
40
+ return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
41
+ }
42
+
43
+ // Count cells holding data in the JSON output of `gog sheets get`
44
+ // ({"values":[[...]]}). Empty strings and whitespace-only cells don't count;
45
+ // numbers (including 0) and other primitives do. Returns -1 when the output
46
+ // can't be parsed, so the caller can fail safe rather than assume "empty".
47
+ export function countNonEmptyCells(getOutput: string): number {
48
+ let parsed: unknown;
49
+ try {
50
+ parsed = JSON.parse(getOutput);
51
+ } catch {
52
+ return -1;
53
+ }
54
+ const values = (parsed as { values?: unknown })?.values;
55
+ if (!Array.isArray(values)) return 0;
56
+ let count = 0;
57
+ for (const row of values) {
58
+ if (!Array.isArray(row)) continue;
59
+ for (const cell of row) {
60
+ if (cell !== null && String(cell).trim() !== '') count++;
61
+ }
62
+ }
63
+ return count;
64
+ }
@@ -1,12 +1,25 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
3
+ import { run } from '../runner.js';
4
+ import { accountParam, runOrDiagnose, registerRunTool, toText, diagnose } from './utils.js';
5
+ import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
4
6
 
5
7
  // Cell value type: matches what gog sheets --values-json accepts (passed
6
8
  // straight to the Sheets API as userEnteredValue). Strings starting with
7
9
  // "=" are treated as formulas by gog's default --input=USER_ENTERED.
8
10
  const cellValueParam = z.union([z.string(), z.number(), z.boolean(), z.null()]);
9
11
 
12
+ // gog sheets update/append/clear all support -n/--dry-run ("Do not make
13
+ // changes; print intended actions and exit successfully"). Exposing it gives
14
+ // agents a no-op preview before committing a write to a live sheet.
15
+ const dryRunParam = z.boolean().optional().describe(
16
+ 'Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing.',
17
+ );
18
+
19
+ const failIfNotEmptyParam = z.boolean().optional().describe(
20
+ 'Safety guard against silent overwrites: before writing, read the target range and refuse the write if any target cell already holds data. Costs one extra read. Anchor ranges (e.g. "Sheet1!A1") are expanded to the full area your values will cover; explicit and named ranges are checked as-is.',
21
+ );
22
+
10
23
  export function registerSheetsTools(server: McpServer): void {
11
24
  server.registerTool('gog_sheets_get', {
12
25
  description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
@@ -27,13 +40,37 @@ export function registerSheetsTools(server: McpServer): void {
27
40
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
28
41
  range: z.string().describe('Top-left cell or range in A1 notation, e.g. Sheet1!A1'),
29
42
  values: z.array(z.array(cellValueParam)).describe('2D array of values (rows of columns). Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
43
+ dry_run: dryRunParam,
44
+ fail_if_not_empty: failIfNotEmptyParam,
30
45
  account: accountParam,
31
46
  },
32
- }, async ({ spreadsheetId, range, values, account }) => {
33
- return runOrDiagnose(
34
- ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
35
- { account },
36
- );
47
+ }, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
48
+ const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
49
+ if (fail_if_not_empty && values.length > 0 && cols > 0) {
50
+ const readRange = expandAnchorRange(range, values.length, cols);
51
+ let existing: string;
52
+ try {
53
+ existing = await run(['sheets', 'get', spreadsheetId, readRange], { account });
54
+ } catch (err) {
55
+ // Couldn't read the target — refuse to write rather than risk an
56
+ // overwrite, and diagnose the read failure (auth/transient/etc.) the
57
+ // same way runOrDiagnose would, since this path bypasses it.
58
+ return diagnose(err);
59
+ }
60
+ const occupied = countNonEmptyCells(existing);
61
+ if (occupied !== 0) {
62
+ const detail = occupied < 0
63
+ ? 'could not be verified as empty'
64
+ : `already contains data in ${occupied} cell(s)`;
65
+ return toText(
66
+ `Write aborted (fail_if_not_empty): target range ${readRange} ${detail}. ` +
67
+ 'Re-run without fail_if_not_empty to overwrite, or clear it first with gog_sheets_clear.',
68
+ );
69
+ }
70
+ }
71
+ const args = ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
72
+ if (dry_run) args.push('--dry-run');
73
+ return runOrDiagnose(args, { account });
37
74
  });
38
75
 
39
76
  server.registerTool('gog_sheets_append', {
@@ -43,13 +80,13 @@ export function registerSheetsTools(server: McpServer): void {
43
80
  spreadsheetId: z.string().describe('Spreadsheet ID (from the URL)'),
44
81
  range: z.string().describe('Range indicating which sheet/columns to append to, e.g. Sheet1!A:C'),
45
82
  values: z.array(z.array(cellValueParam)).describe('2D array of rows to append. Cells may be string/number/boolean/null; strings starting with "=" are formulas.'),
83
+ dry_run: dryRunParam,
46
84
  account: accountParam,
47
85
  },
48
- }, async ({ spreadsheetId, range, values, account }) => {
49
- return runOrDiagnose(
50
- ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`],
51
- { account },
52
- );
86
+ }, async ({ spreadsheetId, range, values, account, dry_run }) => {
87
+ const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
88
+ if (dry_run) args.push('--dry-run');
89
+ return runOrDiagnose(args, { account });
53
90
  });
54
91
 
55
92
  server.registerTool('gog_sheets_clear', {
@@ -58,14 +95,17 @@ export function registerSheetsTools(server: McpServer): void {
58
95
  inputSchema: {
59
96
  spreadsheetId: z.string().describe('Spreadsheet ID'),
60
97
  range: z.string().describe('Range in A1 notation to clear'),
98
+ dry_run: dryRunParam,
61
99
  account: accountParam,
62
100
  },
63
- }, async ({ spreadsheetId, range, account }) => {
64
- return runOrDiagnose(['sheets', 'clear', spreadsheetId, range], { account });
101
+ }, async ({ spreadsheetId, range, account, dry_run }) => {
102
+ const args = ['sheets', 'clear', spreadsheetId, range];
103
+ if (dry_run) args.push('--dry-run');
104
+ return runOrDiagnose(args, { account });
65
105
  });
66
106
 
67
107
  server.registerTool('gog_sheets_metadata', {
68
- description: 'Get spreadsheet metadata: title, sheet tabs, named ranges, and other properties.',
108
+ description: 'Get spreadsheet metadata: title, named ranges, and per-tab properties including grid dimensions (gridProperties.rowCount / columnCount). Use this to learn a sheet\'s current size before writing — a write outside the grid fails.',
69
109
  annotations: { readOnlyHint: true },
70
110
  inputSchema: {
71
111
  spreadsheetId: z.string().describe('Spreadsheet ID'),
@@ -5,7 +5,8 @@ import { run } from '../runner.js';
5
5
  export type ToolResult = { content: [{ type: 'text'; text: string }] };
6
6
 
7
7
  export const accountParam = z.string().optional().describe(
8
- 'Google account email to use (overrides GOG_ACCOUNT env var)',
8
+ 'Google account email to use, e.g. you@gmail.com must be the full address, not a bare username. ' +
9
+ 'Overrides the GOG_ACCOUNT env var. Omit to use the single configured account.',
9
10
  );
10
11
 
11
12
  // Canonical ID descriptors. Use these instead of redefining the same
@@ -100,6 +101,11 @@ const AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid
100
101
  const TRANSIENT_ERROR_PATTERN =
101
102
  /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
102
103
 
104
+ // gogcli rejects writes whose range falls outside the sheet's current grid
105
+ // (e.g. writing to column AP on a 41-column sheet) with a "exceeds grid limits"
106
+ // error and no remediation. Point the caller at the tool that grows the grid.
107
+ const GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
108
+
103
109
  const AUTH_HINT =
104
110
  '\n\nAuthentication may have expired. Use gog_auth_add to re-authorize the account. ' +
105
111
  'Ask the user if they would like to re-authenticate.';
@@ -108,6 +114,57 @@ const TRANSIENT_HINT =
108
114
  '\n\nThis error is often transient. Retry the same call before trying a different approach ' +
109
115
  '(do not fall back to smaller writes or row-by-row operations).';
110
116
 
117
+ const GRID_LIMIT_HINT =
118
+ '\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
119
+ 'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
120
+
121
+ // Reduce `gog auth list --json` output to just the configured email addresses.
122
+ // The raw JSON also carries OAuth scopes, the Google subject id, and creation
123
+ // timestamps — none of which belong in an error surfaced to the model, and
124
+ // which were previously echoed verbatim on every failure. Falls back to the
125
+ // trimmed raw text if the output isn't the expected JSON shape (e.g. a plain
126
+ // email string), so unexpected output still degrades gracefully.
127
+ export function formatAccountList(raw: string): string {
128
+ try {
129
+ const parsed = JSON.parse(raw) as { accounts?: unknown };
130
+ if (Array.isArray(parsed?.accounts)) {
131
+ return parsed.accounts
132
+ .map((a) => (a as { email?: string })?.email)
133
+ .filter(Boolean)
134
+ .join('\n');
135
+ }
136
+ } catch {
137
+ // not JSON — fall through to the raw text
138
+ }
139
+ return raw.trim();
140
+ }
141
+
142
+ // Turn a thrown error into a diagnosed ToolResult: the error text, an
143
+ // actionable hint when the failure class is recognised (auth / transient /
144
+ // off-grid write), and the list of configured accounts. Callers that need to
145
+ // surface a failure without going through runOrDiagnose (e.g. a pre-write
146
+ // verification read that must abort) can reuse this so the error keeps the
147
+ // same diagnostic quality as everywhere else.
148
+ export async function diagnose(err: unknown): Promise<ToolResult> {
149
+ const errText = toError(err).content[0].text;
150
+ const isAuthError = AUTH_ERROR_PATTERN.test(errText);
151
+ const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
152
+ const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
153
+ const hint = isAuthError
154
+ ? AUTH_HINT
155
+ : isTransientError
156
+ ? TRANSIENT_HINT
157
+ : isGridLimitError
158
+ ? GRID_LIMIT_HINT
159
+ : '';
160
+ try {
161
+ const accounts = formatAccountList(await run(['auth', 'list']));
162
+ return toText(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
163
+ } catch {
164
+ return toText(`${errText}${hint}`);
165
+ }
166
+ }
167
+
111
168
  export async function runOrDiagnose(
112
169
  args: string[],
113
170
  options: { account?: string },
@@ -115,16 +172,6 @@ export async function runOrDiagnose(
115
172
  try {
116
173
  return toText(await run(args, options));
117
174
  } catch (err) {
118
- const base = toError(err);
119
- const errText = base.content[0].text;
120
- const isAuthError = AUTH_ERROR_PATTERN.test(errText);
121
- const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
122
- const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : '';
123
- try {
124
- const accounts = await run(['auth', 'list']);
125
- return toText(`${errText}\n\nConfigured accounts:\n${accounts}${hint}`);
126
- } catch {
127
- return toText(`${errText}${hint}`);
128
- }
175
+ return diagnose(err);
129
176
  }
130
177
  }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { expandAnchorRange, countNonEmptyCells } from '../../src/tools/sheets-a1.js';
3
+
4
+ describe('expandAnchorRange', () => {
5
+ it('expands a bare anchor cell to the full written area', () => {
6
+ expect(expandAnchorRange('A1', 3, 2)).toBe('A1:B3');
7
+ });
8
+
9
+ it('preserves a sheet-name prefix when expanding', () => {
10
+ expect(expandAnchorRange('Sheet1!A1', 70, 4)).toBe('Sheet1!A1:D70');
11
+ });
12
+
13
+ it('generates multi-letter end columns past Z', () => {
14
+ expect(expandAnchorRange('A1', 1, 27)).toBe('A1:AA1');
15
+ });
16
+
17
+ it('parses multi-letter start columns', () => {
18
+ expect(expandAnchorRange('AA5', 1, 1)).toBe('AA5:AA5');
19
+ });
20
+
21
+ it('uppercases a lowercase anchor column', () => {
22
+ expect(expandAnchorRange('b2', 1, 1)).toBe('B2:B2');
23
+ });
24
+
25
+ it('leaves an explicit A1 range (with a colon) unchanged', () => {
26
+ expect(expandAnchorRange('Sheet1!A1:D70', 5, 5)).toBe('Sheet1!A1:D70');
27
+ });
28
+
29
+ it('leaves a named range unchanged', () => {
30
+ expect(expandAnchorRange('MyNamedRange', 5, 5)).toBe('MyNamedRange');
31
+ });
32
+ });
33
+
34
+ describe('countNonEmptyCells', () => {
35
+ it('counts cells that hold data across ragged rows', () => {
36
+ expect(countNonEmptyCells('{"values":[["a","b"],["c"]]}')).toBe(3);
37
+ });
38
+
39
+ it('treats empty strings as empty', () => {
40
+ expect(countNonEmptyCells('{"values":[["","",""]]}')).toBe(0);
41
+ });
42
+
43
+ it('treats whitespace-only cells as empty', () => {
44
+ expect(countNonEmptyCells('{"values":[[" "]]}')).toBe(0);
45
+ });
46
+
47
+ it('counts numbers and zero as data, skips nulls', () => {
48
+ expect(countNonEmptyCells('{"values":[[1,0,null,"x"]]}')).toBe(3);
49
+ });
50
+
51
+ it('returns 0 when there is no values key', () => {
52
+ expect(countNonEmptyCells('{}')).toBe(0);
53
+ });
54
+
55
+ it('returns 0 when values is not an array', () => {
56
+ expect(countNonEmptyCells('{"values":"foo"}')).toBe(0);
57
+ });
58
+
59
+ it('returns 0 when the parsed JSON is null', () => {
60
+ expect(countNonEmptyCells('null')).toBe(0);
61
+ });
62
+
63
+ it('skips non-array rows', () => {
64
+ expect(countNonEmptyCells('{"values":[null,["a"]]}')).toBe(1);
65
+ });
66
+
67
+ it('returns -1 when the output is not valid JSON', () => {
68
+ expect(countNonEmptyCells('not json')).toBe(-1);
69
+ });
70
+ });
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { registerSheetsTools } from '../../src/tools/sheets.js';
3
4
  import * as runner from '../../src/runner.js';
4
5
  import { setupHandlers as setupHandlersBase, type ToolHandler } from '../helpers/test-harness.js';
@@ -84,6 +85,135 @@ describe('gog_sheets_update', () => {
84
85
  const result = await handlers.get('gog_sheets_update')!({ spreadsheetId: 'bad', range: 'A1', values: [['x']] });
85
86
  expect(result.content[0].text).toBe('Error: Update failed');
86
87
  });
88
+
89
+ it('appends --dry-run when dry_run is true', async () => {
90
+ vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
91
+ const handlers = setupHandlers();
92
+ const values = [['x']];
93
+ await handlers.get('gog_sheets_update')!({ spreadsheetId: 'sid', range: 'A1', values, dry_run: true });
94
+ expect(runner.run).toHaveBeenCalledWith(
95
+ ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
96
+ { account: undefined },
97
+ );
98
+ });
99
+
100
+ it('omits --dry-run when dry_run is false or unset', async () => {
101
+ vi.mocked(runner.run).mockResolvedValue('{}');
102
+ const handlers = setupHandlers();
103
+ await handlers.get('gog_sheets_update')!({ spreadsheetId: 'sid', range: 'A1', values: [['x']], dry_run: false });
104
+ expect(vi.mocked(runner.run).mock.calls[0]![0]).not.toContain('--dry-run');
105
+ });
106
+ });
107
+
108
+ describe('gog_sheets_update fail_if_not_empty guard', () => {
109
+ it('aborts the write when the target range already holds data', async () => {
110
+ vi.mocked(runner.run).mockResolvedValueOnce('{"values":[["existing"]]}');
111
+ const handlers = setupHandlers();
112
+ const result = await handlers.get('gog_sheets_update')!({
113
+ spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
114
+ });
115
+ // Only the read happened — no update call.
116
+ expect(runner.run).toHaveBeenCalledTimes(1);
117
+ expect(runner.run).toHaveBeenCalledWith(['sheets', 'get', 'sid', 'A1:A1'], { account: undefined });
118
+ expect(result.content[0].text).toContain('Write aborted');
119
+ expect(result.content[0].text).toContain('A1:A1');
120
+ expect(result.content[0].text).toContain('1 cell');
121
+ });
122
+
123
+ it('proceeds with the write when the target range is empty', async () => {
124
+ vi.mocked(runner.run)
125
+ .mockResolvedValueOnce('{}')
126
+ .mockResolvedValueOnce('{"updatedCells":1}');
127
+ const handlers = setupHandlers();
128
+ const values = [['new']];
129
+ const result = await handlers.get('gog_sheets_update')!({
130
+ spreadsheetId: 'sid', range: 'A1', values, fail_if_not_empty: true,
131
+ });
132
+ expect(runner.run).toHaveBeenCalledTimes(2);
133
+ expect(vi.mocked(runner.run).mock.calls[1]![0]).toEqual(
134
+ ['sheets', 'update', 'sid', 'A1', `--values-json=${JSON.stringify(values)}`],
135
+ );
136
+ expect(result.content[0].text).toBe('{"updatedCells":1}');
137
+ });
138
+
139
+ it('expands an anchor cell to the full written area before reading', async () => {
140
+ vi.mocked(runner.run)
141
+ .mockResolvedValueOnce('{}')
142
+ .mockResolvedValueOnce('{}');
143
+ const handlers = setupHandlers();
144
+ await handlers.get('gog_sheets_update')!({
145
+ spreadsheetId: 'sid', range: 'Sheet1!A1',
146
+ values: [['a', 'b'], ['c', 'd'], ['e', 'f']], fail_if_not_empty: true,
147
+ });
148
+ expect(vi.mocked(runner.run).mock.calls[0]![0]).toEqual(['sheets', 'get', 'sid', 'Sheet1!A1:B3']);
149
+ });
150
+
151
+ it('aborts without writing and diagnoses the error when the verification read fails', async () => {
152
+ vi.mocked(runner.run)
153
+ .mockRejectedValueOnce(new Error('read boom')) // the verification get
154
+ .mockResolvedValueOnce('{"accounts":[{"email":"u@x.com"}]}'); // diagnose -> auth list
155
+ const handlers = setupHandlers();
156
+ const result = await handlers.get('gog_sheets_update')!({
157
+ spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
158
+ });
159
+ // get + auth list (for diagnosis), but the update is never attempted
160
+ expect(runner.run).toHaveBeenCalledTimes(2);
161
+ expect(vi.mocked(runner.run).mock.calls.some((c) => c[0][1] === 'update')).toBe(false);
162
+ expect(result.content[0].text).toContain('Error: read boom');
163
+ expect(result.content[0].text).toContain('Configured accounts:');
164
+ });
165
+
166
+ it('surfaces the re-auth hint when the verification read fails with an auth error', async () => {
167
+ vi.mocked(runner.run)
168
+ .mockRejectedValueOnce(new Error('Request failed with status 401'))
169
+ .mockResolvedValueOnce('{"accounts":[{"email":"u@x.com"}]}');
170
+ const handlers = setupHandlers();
171
+ const result = await handlers.get('gog_sheets_update')!({
172
+ spreadsheetId: 'sid', range: 'A1', values: [['x']], fail_if_not_empty: true,
173
+ });
174
+ expect(result.content[0].text).toContain('gog_auth_add');
175
+ });
176
+
177
+ it('aborts when emptiness cannot be verified from unparseable output', async () => {
178
+ vi.mocked(runner.run).mockResolvedValueOnce('not json');
179
+ const handlers = setupHandlers();
180
+ const result = await handlers.get('gog_sheets_update')!({
181
+ spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true,
182
+ });
183
+ expect(runner.run).toHaveBeenCalledTimes(1);
184
+ expect(result.content[0].text).toContain('could not be verified');
185
+ });
186
+
187
+ it('still honors dry_run after the guard passes', async () => {
188
+ vi.mocked(runner.run)
189
+ .mockResolvedValueOnce('{}')
190
+ .mockResolvedValueOnce('{"dryRun":true}');
191
+ const handlers = setupHandlers();
192
+ await handlers.get('gog_sheets_update')!({
193
+ spreadsheetId: 'sid', range: 'A1', values: [['new']], fail_if_not_empty: true, dry_run: true,
194
+ });
195
+ expect(vi.mocked(runner.run).mock.calls[1]![0]).toContain('--dry-run');
196
+ });
197
+
198
+ it('skips the guard read when values has no rows', async () => {
199
+ vi.mocked(runner.run).mockResolvedValue('{}');
200
+ const handlers = setupHandlers();
201
+ await handlers.get('gog_sheets_update')!({
202
+ spreadsheetId: 'sid', range: 'A1', values: [], fail_if_not_empty: true,
203
+ });
204
+ expect(runner.run).toHaveBeenCalledTimes(1);
205
+ expect(vi.mocked(runner.run).mock.calls[0]![0]![1]).toBe('update');
206
+ });
207
+
208
+ it('skips the guard read when rows have no columns', async () => {
209
+ vi.mocked(runner.run).mockResolvedValue('{}');
210
+ const handlers = setupHandlers();
211
+ await handlers.get('gog_sheets_update')!({
212
+ spreadsheetId: 'sid', range: 'A1', values: [[]], fail_if_not_empty: true,
213
+ });
214
+ expect(runner.run).toHaveBeenCalledTimes(1);
215
+ expect(vi.mocked(runner.run).mock.calls[0]![0]![1]).toBe('update');
216
+ });
87
217
  });
88
218
 
89
219
  describe('gog_sheets_append', () => {
@@ -104,6 +234,17 @@ describe('gog_sheets_append', () => {
104
234
  const result = await handlers.get('gog_sheets_append')!({ spreadsheetId: 'bad', range: 'A1', values: [['x']] });
105
235
  expect(result.content[0].text).toBe('Error: Append failed');
106
236
  });
237
+
238
+ it('appends --dry-run when dry_run is true', async () => {
239
+ vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
240
+ const handlers = setupHandlers();
241
+ const values = [['x']];
242
+ await handlers.get('gog_sheets_append')!({ spreadsheetId: 'sid', range: 'Sheet1!A:A', values, dry_run: true });
243
+ expect(runner.run).toHaveBeenCalledWith(
244
+ ['sheets', 'append', 'sid', 'Sheet1!A:A', `--values-json=${JSON.stringify(values)}`, '--dry-run'],
245
+ { account: undefined },
246
+ );
247
+ });
107
248
  });
108
249
 
109
250
  describe('gog_sheets_clear', () => {
@@ -120,9 +261,30 @@ describe('gog_sheets_clear', () => {
120
261
  const result = await handlers.get('gog_sheets_clear')!({ spreadsheetId: 'bad', range: 'A1' });
121
262
  expect(result.content[0].text).toBe('Error: Clear failed');
122
263
  });
264
+
265
+ it('appends --dry-run when dry_run is true', async () => {
266
+ vi.mocked(runner.run).mockResolvedValue('{"dryRun":true}');
267
+ const handlers = setupHandlers();
268
+ await handlers.get('gog_sheets_clear')!({ spreadsheetId: 'sid', range: 'Sheet1!A1:Z100', dry_run: true });
269
+ expect(runner.run).toHaveBeenCalledWith(
270
+ ['sheets', 'clear', 'sid', 'Sheet1!A1:Z100', '--dry-run'],
271
+ { account: undefined },
272
+ );
273
+ });
123
274
  });
124
275
 
125
276
  describe('gog_sheets_metadata', () => {
277
+ it('advertises grid dimensions in its description', () => {
278
+ const server = new McpServer({ name: 'test', version: '0.0.0' });
279
+ const configs = new Map<string, { description?: string }>();
280
+ vi.spyOn(server, 'registerTool').mockImplementation((name, config) => {
281
+ configs.set(name, config as { description?: string });
282
+ return undefined as never;
283
+ });
284
+ registerSheetsTools(server);
285
+ expect(configs.get('gog_sheets_metadata')!.description).toMatch(/grid dimensions|rowCount|columnCount/i);
286
+ });
287
+
126
288
  it('calls run with spreadsheetId only', async () => {
127
289
  vi.mocked(runner.run).mockResolvedValue('{"title":"My Sheet","sheets":[{"title":"Sheet1"}]}');
128
290
  const handlers = setupHandlers();
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as runner from '../../src/runner.js';
3
- import { runOrDiagnose, pushPaginationFlags } from '../../src/tools/utils.js';
3
+ import { runOrDiagnose, pushPaginationFlags, formatAccountList } from '../../src/tools/utils.js';
4
4
 
5
5
  vi.mock('../../src/runner.js');
6
6
 
@@ -40,6 +40,53 @@ describe('pushPaginationFlags', () => {
40
40
  });
41
41
  });
42
42
 
43
+ describe('formatAccountList', () => {
44
+ const AUTH_LIST_JSON = JSON.stringify({
45
+ accounts: [
46
+ {
47
+ email: 'chris.c.hall@gmail.com',
48
+ subject: '109876543210987654321',
49
+ client: 'default',
50
+ services: ['gmail', 'drive'],
51
+ scopes: ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/drive'],
52
+ created_at: '2026-01-02T03:04:05Z',
53
+ },
54
+ ],
55
+ });
56
+
57
+ it('reduces gog auth list JSON to email addresses only', () => {
58
+ const out = formatAccountList(AUTH_LIST_JSON);
59
+ expect(out).toBe('chris.c.hall@gmail.com');
60
+ // none of the sensitive fields leak through
61
+ expect(out).not.toContain('scopes');
62
+ expect(out).not.toContain('gmail.modify');
63
+ expect(out).not.toContain('109876543210987654321');
64
+ expect(out).not.toContain('created_at');
65
+ });
66
+
67
+ it('joins multiple account emails with newlines', () => {
68
+ const out = formatAccountList(JSON.stringify({ accounts: [{ email: 'a@x.com' }, { email: 'b@y.com' }] }));
69
+ expect(out).toBe('a@x.com\nb@y.com');
70
+ });
71
+
72
+ it('skips accounts with no email', () => {
73
+ const out = formatAccountList(JSON.stringify({ accounts: [{ email: 'a@x.com' }, { client: 'other' }] }));
74
+ expect(out).toBe('a@x.com');
75
+ });
76
+
77
+ it('falls back to the trimmed raw text when not parseable JSON', () => {
78
+ expect(formatAccountList(' user@gmail.com\n')).toBe('user@gmail.com');
79
+ });
80
+
81
+ it('falls back to raw text when JSON has no accounts array', () => {
82
+ expect(formatAccountList('{"foo":1}')).toBe('{"foo":1}');
83
+ });
84
+
85
+ it('falls back to raw text when parsed JSON is null', () => {
86
+ expect(formatAccountList('null')).toBe('null');
87
+ });
88
+ });
89
+
43
90
  describe('runOrDiagnose', () => {
44
91
  it('returns output text on success', async () => {
45
92
  vi.mocked(runner.run).mockResolvedValue('{"ok":true}');
@@ -58,6 +105,35 @@ describe('runOrDiagnose', () => {
58
105
  expect(result.content[0].text).not.toContain('gog_auth_add');
59
106
  });
60
107
 
108
+ it('redacts scopes/subject/timestamps from the configured-accounts block', async () => {
109
+ const authListJson = JSON.stringify({
110
+ accounts: [{
111
+ email: 'chris.c.hall@gmail.com',
112
+ subject: '109876543210987654321',
113
+ scopes: ['https://www.googleapis.com/auth/gmail.modify'],
114
+ created_at: '2026-01-02T03:04:05Z',
115
+ }],
116
+ });
117
+ vi.mocked(runner.run)
118
+ .mockRejectedValueOnce(new Error('refusing to delete gmail draft r123 without --force (non-interactive)'))
119
+ .mockResolvedValueOnce(authListJson);
120
+ const result = await runOrDiagnose(['gmail', 'drafts', 'delete', 'r123'], {});
121
+ const text = result.content[0].text;
122
+ expect(text).toContain('Configured accounts:\nchris.c.hall@gmail.com');
123
+ expect(text).not.toContain('scopes');
124
+ expect(text).not.toContain('gmail.modify');
125
+ expect(text).not.toContain('109876543210987654321');
126
+ expect(text).not.toContain('created_at');
127
+ });
128
+
129
+ it('shows (none) when no accounts are configured', async () => {
130
+ vi.mocked(runner.run)
131
+ .mockRejectedValueOnce(new Error('Doc not found'))
132
+ .mockResolvedValueOnce('{"accounts":[]}');
133
+ const result = await runOrDiagnose(['docs', 'cat', 'abc'], {});
134
+ expect(result.content[0].text).toBe('Error: Doc not found\n\nConfigured accounts:\n(none)');
135
+ });
136
+
61
137
  it('appends re-auth hint on 401 error', async () => {
62
138
  vi.mocked(runner.run)
63
139
  .mockRejectedValueOnce(new Error('Request failed with status 401'))
@@ -175,4 +251,30 @@ describe('runOrDiagnose', () => {
175
251
  expect(result.content[0].text).toContain('transient');
176
252
  expect(result.content[0].text).not.toContain('Configured accounts');
177
253
  });
254
+
255
+ it('appends grid-limit hint pointing at gog_sheets_insert', async () => {
256
+ vi.mocked(runner.run)
257
+ .mockRejectedValueOnce(new Error('Range (Sheet1!AP1:AW1) exceeds grid limits. Max rows: 1000, max columns: 41'))
258
+ .mockResolvedValueOnce('user@gmail.com');
259
+ const result = await runOrDiagnose(['sheets', 'update', 'abc', 'AP1'], {});
260
+ expect(result.content[0].text).toContain('exceeds grid limits');
261
+ expect(result.content[0].text).toContain('gog_sheets_insert');
262
+ });
263
+
264
+ it('does not append grid-limit hint on unrelated errors', async () => {
265
+ vi.mocked(runner.run)
266
+ .mockRejectedValueOnce(new Error('Spreadsheet not found'))
267
+ .mockResolvedValueOnce('user@gmail.com');
268
+ const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
269
+ expect(result.content[0].text).not.toContain('gog_sheets_insert');
270
+ });
271
+
272
+ it('keeps grid-limit hint when auth list also fails', async () => {
273
+ vi.mocked(runner.run)
274
+ .mockRejectedValueOnce(new Error('exceeds grid limits. Max rows: 1000, max columns: 41'))
275
+ .mockRejectedValueOnce(new Error('auth list failed'));
276
+ const result = await runOrDiagnose(['sheets', 'update', 'abc', 'A1'], {});
277
+ expect(result.content[0].text).toContain('gog_sheets_insert');
278
+ expect(result.content[0].text).not.toContain('Configured accounts');
279
+ });
178
280
  });