gogcli-mcp 2.0.12 → 2.3.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +42 -0
- package/dist/index.js +120 -30
- package/dist/lib.js +122 -30
- package/manifest.json +1 -1
- package/package.json +3 -3
- package/server.json +2 -2
- package/src/lib.ts +1 -1
- package/src/runner.ts +8 -0
- package/src/tools/gmail.ts +1 -1
- package/src/tools/sheets-a1.ts +64 -0
- package/src/tools/sheets.ts +54 -14
- package/src/tools/utils.ts +59 -12
- package/tests/tools/sheets-a1.test.ts +70 -0
- package/tests/tools/sheets.test.ts +162 -0
- package/tests/tools/utils.test.ts +103 -1
|
@@ -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
|
|
10
|
+
"version": "2.3.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
|
|
18
|
+
"version": "2.3.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
32018
|
-
|
|
32019
|
-
|
|
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
|
-
|
|
32033
|
-
|
|
32034
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
32325
|
+
var VERSION = true ? "2.3.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
|
@@ -30859,6 +30859,7 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
30859
30859
|
import { spawn } from "node:child_process";
|
|
30860
30860
|
import { delimiter } from "node:path";
|
|
30861
30861
|
var TIMEOUT_MS = 3e4;
|
|
30862
|
+
var MIN_GOG_VERSION = "0.19.0";
|
|
30862
30863
|
function envOrUndefined(key) {
|
|
30863
30864
|
const value = process.env[key];
|
|
30864
30865
|
if (!value || value.startsWith("${")) return void 0;
|
|
@@ -30981,7 +30982,7 @@ async function run(args, options = {}) {
|
|
|
30981
30982
|
|
|
30982
30983
|
// src/tools/utils.ts
|
|
30983
30984
|
var accountParam = external_exports.string().optional().describe(
|
|
30984
|
-
"Google account email to use
|
|
30985
|
+
"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
30986
|
);
|
|
30986
30987
|
var ids = {
|
|
30987
30988
|
course: external_exports.string().describe("Course ID"),
|
|
@@ -31045,26 +31046,41 @@ function toError(err) {
|
|
|
31045
31046
|
}
|
|
31046
31047
|
var AUTH_ERROR_PATTERN = /\b(401|unauthorized|token.*(expired|revoked)|invalid_grant)\b/i;
|
|
31047
31048
|
var TRANSIENT_ERROR_PATTERN = /\b429\b|\b5\d\d\b|\bquota\b|rateLimit|\bDEADLINE_EXCEEDED\b/i;
|
|
31049
|
+
var GRID_LIMIT_ERROR_PATTERN = /exceeds grid limits/i;
|
|
31048
31050
|
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
31051
|
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).";
|
|
31052
|
+
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.";
|
|
31053
|
+
function formatAccountList(raw) {
|
|
31054
|
+
try {
|
|
31055
|
+
const parsed = JSON.parse(raw);
|
|
31056
|
+
if (Array.isArray(parsed?.accounts)) {
|
|
31057
|
+
return parsed.accounts.map((a) => a?.email).filter(Boolean).join("\n");
|
|
31058
|
+
}
|
|
31059
|
+
} catch {
|
|
31060
|
+
}
|
|
31061
|
+
return raw.trim();
|
|
31062
|
+
}
|
|
31063
|
+
async function diagnose(err) {
|
|
31064
|
+
const errText = toError(err).content[0].text;
|
|
31065
|
+
const isAuthError = AUTH_ERROR_PATTERN.test(errText);
|
|
31066
|
+
const isTransientError = !isAuthError && TRANSIENT_ERROR_PATTERN.test(errText);
|
|
31067
|
+
const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
|
|
31068
|
+
const hint = isAuthError ? AUTH_HINT : isTransientError ? TRANSIENT_HINT : isGridLimitError ? GRID_LIMIT_HINT : "";
|
|
31069
|
+
try {
|
|
31070
|
+
const accounts = formatAccountList(await run(["auth", "list"]));
|
|
31071
|
+
return toText(`${errText}
|
|
31072
|
+
|
|
31073
|
+
Configured accounts:
|
|
31074
|
+
${accounts || "(none)"}${hint}`);
|
|
31075
|
+
} catch {
|
|
31076
|
+
return toText(`${errText}${hint}`);
|
|
31077
|
+
}
|
|
31078
|
+
}
|
|
31050
31079
|
async function runOrDiagnose(args, options) {
|
|
31051
31080
|
try {
|
|
31052
31081
|
return toText(await run(args, options));
|
|
31053
31082
|
} catch (err) {
|
|
31054
|
-
|
|
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
|
-
}
|
|
31083
|
+
return diagnose(err);
|
|
31068
31084
|
}
|
|
31069
31085
|
}
|
|
31070
31086
|
|
|
@@ -31853,7 +31869,7 @@ function registerDriveTools(server) {
|
|
|
31853
31869
|
// src/tools/gmail.ts
|
|
31854
31870
|
function registerGmailTools(server) {
|
|
31855
31871
|
server.registerTool("gog_gmail_search", {
|
|
31856
|
-
description:
|
|
31872
|
+
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
31873
|
annotations: { readOnlyHint: true },
|
|
31858
31874
|
inputSchema: {
|
|
31859
31875
|
query: external_exports.string().describe("Gmail search query"),
|
|
@@ -31902,8 +31918,62 @@ function registerGmailTools(server) {
|
|
|
31902
31918
|
registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
|
|
31903
31919
|
}
|
|
31904
31920
|
|
|
31921
|
+
// src/tools/sheets-a1.ts
|
|
31922
|
+
function colToLetter(n) {
|
|
31923
|
+
let s = "";
|
|
31924
|
+
while (n > 0) {
|
|
31925
|
+
const rem = (n - 1) % 26;
|
|
31926
|
+
s = String.fromCharCode(65 + rem) + s;
|
|
31927
|
+
n = Math.floor((n - 1) / 26);
|
|
31928
|
+
}
|
|
31929
|
+
return s;
|
|
31930
|
+
}
|
|
31931
|
+
function letterToCol(s) {
|
|
31932
|
+
let n = 0;
|
|
31933
|
+
for (const ch of s.toUpperCase()) {
|
|
31934
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
31935
|
+
}
|
|
31936
|
+
return n;
|
|
31937
|
+
}
|
|
31938
|
+
function expandAnchorRange(range, rows, cols) {
|
|
31939
|
+
const bang = range.lastIndexOf("!");
|
|
31940
|
+
const sheet = bang >= 0 ? range.slice(0, bang + 1) : "";
|
|
31941
|
+
const cell = bang >= 0 ? range.slice(bang + 1) : range;
|
|
31942
|
+
const m = /^([A-Za-z]+)([0-9]+)$/.exec(cell);
|
|
31943
|
+
if (!m) return range;
|
|
31944
|
+
const startCol = letterToCol(m[1]);
|
|
31945
|
+
const startRow = parseInt(m[2], 10);
|
|
31946
|
+
const endCol = colToLetter(startCol + cols - 1);
|
|
31947
|
+
const endRow = startRow + rows - 1;
|
|
31948
|
+
return `${sheet}${m[1].toUpperCase()}${startRow}:${endCol}${endRow}`;
|
|
31949
|
+
}
|
|
31950
|
+
function countNonEmptyCells(getOutput) {
|
|
31951
|
+
let parsed;
|
|
31952
|
+
try {
|
|
31953
|
+
parsed = JSON.parse(getOutput);
|
|
31954
|
+
} catch {
|
|
31955
|
+
return -1;
|
|
31956
|
+
}
|
|
31957
|
+
const values = parsed?.values;
|
|
31958
|
+
if (!Array.isArray(values)) return 0;
|
|
31959
|
+
let count = 0;
|
|
31960
|
+
for (const row of values) {
|
|
31961
|
+
if (!Array.isArray(row)) continue;
|
|
31962
|
+
for (const cell of row) {
|
|
31963
|
+
if (cell !== null && String(cell).trim() !== "") count++;
|
|
31964
|
+
}
|
|
31965
|
+
}
|
|
31966
|
+
return count;
|
|
31967
|
+
}
|
|
31968
|
+
|
|
31905
31969
|
// src/tools/sheets.ts
|
|
31906
31970
|
var cellValueParam = external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean(), external_exports.null()]);
|
|
31971
|
+
var dryRunParam = external_exports.boolean().optional().describe(
|
|
31972
|
+
"Preview the operation without modifying the sheet (gog --dry-run): reports the intended actions and exits without writing."
|
|
31973
|
+
);
|
|
31974
|
+
var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
31975
|
+
'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.'
|
|
31976
|
+
);
|
|
31907
31977
|
function registerSheetsTools(server) {
|
|
31908
31978
|
server.registerTool("gog_sheets_get", {
|
|
31909
31979
|
description: 'Read values from a Google Sheets range. Returns a JSON object with a "values" array of rows.',
|
|
@@ -31923,13 +31993,31 @@ function registerSheetsTools(server) {
|
|
|
31923
31993
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
|
|
31924
31994
|
range: external_exports.string().describe("Top-left cell or range in A1 notation, e.g. Sheet1!A1"),
|
|
31925
31995
|
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.'),
|
|
31996
|
+
dry_run: dryRunParam,
|
|
31997
|
+
fail_if_not_empty: failIfNotEmptyParam,
|
|
31926
31998
|
account: accountParam
|
|
31927
31999
|
}
|
|
31928
|
-
}, async ({ spreadsheetId, range, values, account }) => {
|
|
31929
|
-
|
|
31930
|
-
|
|
31931
|
-
|
|
31932
|
-
|
|
32000
|
+
}, async ({ spreadsheetId, range, values, account, dry_run, fail_if_not_empty }) => {
|
|
32001
|
+
const cols = values.reduce((max, row) => Math.max(max, row.length), 0);
|
|
32002
|
+
if (fail_if_not_empty && values.length > 0 && cols > 0) {
|
|
32003
|
+
const readRange = expandAnchorRange(range, values.length, cols);
|
|
32004
|
+
let existing;
|
|
32005
|
+
try {
|
|
32006
|
+
existing = await run(["sheets", "get", spreadsheetId, readRange], { account });
|
|
32007
|
+
} catch (err) {
|
|
32008
|
+
return diagnose(err);
|
|
32009
|
+
}
|
|
32010
|
+
const occupied = countNonEmptyCells(existing);
|
|
32011
|
+
if (occupied !== 0) {
|
|
32012
|
+
const detail = occupied < 0 ? "could not be verified as empty" : `already contains data in ${occupied} cell(s)`;
|
|
32013
|
+
return toText(
|
|
32014
|
+
`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.`
|
|
32015
|
+
);
|
|
32016
|
+
}
|
|
32017
|
+
}
|
|
32018
|
+
const args = ["sheets", "update", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
32019
|
+
if (dry_run) args.push("--dry-run");
|
|
32020
|
+
return runOrDiagnose(args, { account });
|
|
31933
32021
|
});
|
|
31934
32022
|
server.registerTool("gog_sheets_append", {
|
|
31935
32023
|
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 +32026,13 @@ function registerSheetsTools(server) {
|
|
|
31938
32026
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID (from the URL)"),
|
|
31939
32027
|
range: external_exports.string().describe("Range indicating which sheet/columns to append to, e.g. Sheet1!A:C"),
|
|
31940
32028
|
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.'),
|
|
32029
|
+
dry_run: dryRunParam,
|
|
31941
32030
|
account: accountParam
|
|
31942
32031
|
}
|
|
31943
|
-
}, async ({ spreadsheetId, range, values, account }) => {
|
|
31944
|
-
|
|
31945
|
-
|
|
31946
|
-
|
|
31947
|
-
);
|
|
32032
|
+
}, async ({ spreadsheetId, range, values, account, dry_run }) => {
|
|
32033
|
+
const args = ["sheets", "append", spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
32034
|
+
if (dry_run) args.push("--dry-run");
|
|
32035
|
+
return runOrDiagnose(args, { account });
|
|
31948
32036
|
});
|
|
31949
32037
|
server.registerTool("gog_sheets_clear", {
|
|
31950
32038
|
description: "Clear all values in a Google Sheets range (formatting is preserved).",
|
|
@@ -31952,13 +32040,16 @@ function registerSheetsTools(server) {
|
|
|
31952
32040
|
inputSchema: {
|
|
31953
32041
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
31954
32042
|
range: external_exports.string().describe("Range in A1 notation to clear"),
|
|
32043
|
+
dry_run: dryRunParam,
|
|
31955
32044
|
account: accountParam
|
|
31956
32045
|
}
|
|
31957
|
-
}, async ({ spreadsheetId, range, account }) => {
|
|
31958
|
-
|
|
32046
|
+
}, async ({ spreadsheetId, range, account, dry_run }) => {
|
|
32047
|
+
const args = ["sheets", "clear", spreadsheetId, range];
|
|
32048
|
+
if (dry_run) args.push("--dry-run");
|
|
32049
|
+
return runOrDiagnose(args, { account });
|
|
31959
32050
|
});
|
|
31960
32051
|
server.registerTool("gog_sheets_metadata", {
|
|
31961
|
-
description: "Get spreadsheet metadata: title,
|
|
32052
|
+
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
32053
|
annotations: { readOnlyHint: true },
|
|
31963
32054
|
inputSchema: {
|
|
31964
32055
|
spreadsheetId: external_exports.string().describe("Spreadsheet ID"),
|
|
@@ -32144,7 +32235,7 @@ function registerTasksTools(server) {
|
|
|
32144
32235
|
}
|
|
32145
32236
|
|
|
32146
32237
|
// src/server.ts
|
|
32147
|
-
var VERSION = true ? "2.0
|
|
32238
|
+
var VERSION = true ? "2.3.0" : "0.0.0";
|
|
32148
32239
|
function createServer(options) {
|
|
32149
32240
|
return new McpServer({
|
|
32150
32241
|
name: options?.name ?? "gogcli",
|
|
@@ -32166,6 +32257,7 @@ function createBaseServer(options) {
|
|
|
32166
32257
|
return server;
|
|
32167
32258
|
}
|
|
32168
32259
|
export {
|
|
32260
|
+
MIN_GOG_VERSION,
|
|
32169
32261
|
VERSION,
|
|
32170
32262
|
accountParam,
|
|
32171
32263
|
createBaseServer,
|
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
|
|
6
|
+
"version": "2.3.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
|
|
3
|
+
"version": "2.3.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.
|
|
49
|
-
"@vitest/coverage-v8": "^4.1.
|
|
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
|
|
10
|
+
"version": "2.3.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.0
|
|
15
|
+
"version": "2.3.0",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/lib.ts
CHANGED
|
@@ -13,7 +13,7 @@ export {
|
|
|
13
13
|
registerSlidesTools,
|
|
14
14
|
registerTasksTools,
|
|
15
15
|
} from './server.js';
|
|
16
|
-
export { run } from './runner.js';
|
|
16
|
+
export { run, MIN_GOG_VERSION } from './runner.js';
|
|
17
17
|
export type { RunOptions, Spawner } from './runner.js';
|
|
18
18
|
export {
|
|
19
19
|
accountParam,
|
package/src/runner.ts
CHANGED
|
@@ -17,6 +17,14 @@ export interface RunOptions {
|
|
|
17
17
|
|
|
18
18
|
const TIMEOUT_MS = 30_000;
|
|
19
19
|
|
|
20
|
+
// Minimum gogcli (`gog`) binary version this wrapper's tools assume. Some tools
|
|
21
|
+
// pass flags/subcommands that only exist in newer gog, so bump this whenever a
|
|
22
|
+
// change starts relying on a newer gog feature — and label that PR `gogcli-bump`
|
|
23
|
+
// so the requirement change is surfaced in the release notes (see
|
|
24
|
+
// .github/release.yml). This is the single source of truth for the required
|
|
25
|
+
// version; keep the README/CLAUDE.md mention in sync.
|
|
26
|
+
export const MIN_GOG_VERSION = '0.19.0';
|
|
27
|
+
|
|
20
28
|
// Treat unresolved .mcpb placeholders ("${user_config.gog_path}") and empty
|
|
21
29
|
// strings the same as an unset env var. When an optional .mcpb user_config
|
|
22
30
|
// field is left blank, some clients pass the literal placeholder text through
|
package/src/tools/gmail.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/sheets.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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,
|
|
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'),
|
package/src/tools/utils.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|