gogcli-mcp-sheets 4.5.0 → 4.5.2
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/dist/index.js +698 -62
- package/manifest.json +4 -4
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/tools/sheets-extra.ts +117 -5
- package/tests/tools/sheets-extra-gates.test.ts +158 -0
- package/tests/tools/sheets-extra.test.ts +4 -1
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-sheets",
|
|
5
5
|
"display_name": "gogcli (Sheets)",
|
|
6
|
-
"version": "4.5.
|
|
6
|
+
"version": "4.5.2",
|
|
7
7
|
"description": "Extended Google Sheets for Claude via gogcli — auth + full Sheets support",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -349,15 +349,15 @@
|
|
|
349
349
|
},
|
|
350
350
|
{
|
|
351
351
|
"name": "gog_sheets_datasource_add",
|
|
352
|
-
"description": "Add a BigQuery Connected Sheets data source (custom SQL or a native table)"
|
|
352
|
+
"description": "Add a BigQuery Connected Sheets data source (custom SQL or a native table) (billed; asks the user to confirm)"
|
|
353
353
|
},
|
|
354
354
|
{
|
|
355
355
|
"name": "gog_sheets_datasource_update",
|
|
356
|
-
"description": "Repoint a Connected Sheets data source's SQL, table or billing project"
|
|
356
|
+
"description": "Repoint a Connected Sheets data source's SQL, table or billing project (billed; asks the user to confirm)"
|
|
357
357
|
},
|
|
358
358
|
{
|
|
359
359
|
"name": "gog_sheets_datasource_refresh",
|
|
360
|
-
"description": "Re-run a Connected Sheets data source against current BigQuery data"
|
|
360
|
+
"description": "Re-run a Connected Sheets data source against current BigQuery data (billed; asks the user to confirm)"
|
|
361
361
|
},
|
|
362
362
|
{
|
|
363
363
|
"name": "gog_sheets_datasource_delete",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-sheets",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.2",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-sheets",
|
|
5
5
|
"description": "Extended Google Sheets MCP server via gogcli — all base tools plus full Sheets support",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@chrischall/mcp-utils": "^2.6.0",
|
|
28
|
-
"@modelcontextprotocol/server": "^2.
|
|
28
|
+
"@modelcontextprotocol/server": "^2.1.0",
|
|
29
29
|
"zod": "^4.6.1"
|
|
30
30
|
},
|
|
31
31
|
"license": "MIT",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp-sheets"
|
|
9
9
|
},
|
|
10
|
-
"version": "4.5.
|
|
10
|
+
"version": "4.5.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp-sheets",
|
|
15
|
-
"version": "4.5.
|
|
15
|
+
"version": "4.5.2",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
|
@@ -2,7 +2,20 @@ import { McpServer } from '@modelcontextprotocol/server';
|
|
|
2
2
|
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
accountParam,
|
|
7
|
+
runOrDiagnose,
|
|
8
|
+
run,
|
|
9
|
+
diagnose,
|
|
10
|
+
errorText,
|
|
11
|
+
payloadArg,
|
|
12
|
+
pos,
|
|
13
|
+
confinePath,
|
|
14
|
+
confineAtFile,
|
|
15
|
+
CONFIRM_FALLBACK_DESCRIPTION,
|
|
16
|
+
confirmTokenParam,
|
|
17
|
+
requireDispatchConfirmation,
|
|
18
|
+
} from '../../../gogcli-mcp/src/lib.js';
|
|
6
19
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
20
|
|
|
8
21
|
// Pull the text out of a single-text-block tool result; undefined for any
|
|
@@ -12,6 +25,44 @@ function resultText(result: CallToolResult): string | undefined {
|
|
|
12
25
|
return first?.type === 'text' ? first.text : undefined;
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
/** A spreadsheet's title from `gog sheets metadata --select=properties.title`. Unreadable output names nothing. */
|
|
29
|
+
export function spreadsheetTitle(raw: string | undefined): string | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(String(raw)) as { properties?: { title?: unknown }; spreadsheet?: { properties?: { title?: unknown } } } | null;
|
|
32
|
+
const title = parsed?.spreadsheet?.properties?.title ?? parsed?.properties?.title;
|
|
33
|
+
return typeof title === 'string' ? title : undefined;
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What a data source queries and who pays, from `gog sheets datasource describe`,
|
|
41
|
+
* for a refresh's prompt. Best effort: only the fields found are named.
|
|
42
|
+
*/
|
|
43
|
+
export function dataSourceSnapshot(raw: string | undefined): { billingProject?: string; query?: string; table?: string } {
|
|
44
|
+
type BigQuerySpec = {
|
|
45
|
+
projectId?: unknown;
|
|
46
|
+
querySpec?: { rawQuery?: unknown };
|
|
47
|
+
tableSpec?: { tableProjectId?: unknown; datasetId?: unknown; tableId?: unknown };
|
|
48
|
+
};
|
|
49
|
+
let bq: BigQuerySpec | undefined;
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(String(raw)) as ({ dataSource?: { spec?: { bigQuery?: BigQuerySpec } }; spec?: { bigQuery?: BigQuerySpec } }) | null;
|
|
52
|
+
bq = (parsed?.dataSource ?? parsed)?.spec?.bigQuery;
|
|
53
|
+
} catch {
|
|
54
|
+
bq = undefined;
|
|
55
|
+
}
|
|
56
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
|
|
57
|
+
const t = bq?.tableSpec;
|
|
58
|
+
const table = [t?.tableProjectId, t?.datasetId, t?.tableId].map(str).filter(Boolean).join('.');
|
|
59
|
+
return {
|
|
60
|
+
...(str(bq?.projectId) ? { billingProject: str(bq?.projectId) } : {}),
|
|
61
|
+
...(str(bq?.querySpec?.rawQuery) ? { query: str(bq?.querySpec?.rawQuery) } : {}),
|
|
62
|
+
...(table ? { table } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
15
66
|
// Convert a CSS-style hex color ("#FFF5D9", "#FD9", "FFF5D9") to the
|
|
16
67
|
// {red, green, blue} 0-1 float triple that Sheets API CellFormat expects.
|
|
17
68
|
// Returns null on unparseable input — caller decides whether to fall back
|
|
@@ -77,6 +128,42 @@ async function checkDateFormatTarget(
|
|
|
77
128
|
);
|
|
78
129
|
}
|
|
79
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Fleet audit 2026-09-24 SEC-6: every datasource add / update / refresh starts
|
|
133
|
+
* a BigQuery job billed to a project, so each asks first — naming the
|
|
134
|
+
* spreadsheet by title, what will be queried and who pays.
|
|
135
|
+
*/
|
|
136
|
+
async function confirmBilledExecution(
|
|
137
|
+
ctx: Parameters<typeof requireDispatchConfirmation>[0],
|
|
138
|
+
o: {
|
|
139
|
+
tool: string;
|
|
140
|
+
action: string;
|
|
141
|
+
account: string | undefined;
|
|
142
|
+
confirmToken: string | undefined;
|
|
143
|
+
spreadsheetId: string;
|
|
144
|
+
target: string;
|
|
145
|
+
view: Record<string, unknown>;
|
|
146
|
+
},
|
|
147
|
+
) {
|
|
148
|
+
const got = await runOrDiagnose(['sheets', 'metadata', pos(o.spreadsheetId), '--select=properties.title'], { account: o.account });
|
|
149
|
+
if (got.isError) return got;
|
|
150
|
+
const title = spreadsheetTitle(resultText(got));
|
|
151
|
+
const view = { spreadsheet: { id: o.spreadsheetId, ...(title !== undefined ? { title } : {}) }, ...o.view };
|
|
152
|
+
return requireDispatchConfirmation(ctx, {
|
|
153
|
+
action: o.action,
|
|
154
|
+
message: 'Review and confirm starting a BigQuery execution — it is billed to the billing project:',
|
|
155
|
+
confirmationLabel: 'Confirm that this billed BigQuery query should run now.',
|
|
156
|
+
details: view,
|
|
157
|
+
unsupportedNote: 'Ask the user to run it from Google Sheets (Data > Data connectors).',
|
|
158
|
+
fallback: {
|
|
159
|
+
tool: o.tool,
|
|
160
|
+
account: o.account,
|
|
161
|
+
confirmToken: o.confirmToken,
|
|
162
|
+
subject: () => ({ target: o.target, payload: view, preview: view }),
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
80
167
|
export function registerExtraSheetsTools(server: McpServer): void {
|
|
81
168
|
|
|
82
169
|
server.registerTool('gog_sheets_list_tabs', {
|
|
@@ -1144,7 +1231,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1144
1231
|
const bigQueryChargeNote =
|
|
1145
1232
|
' COSTS MONEY AND RUNS ASYNCHRONOUSLY: this starts a BigQuery execution billed to the billing project, and returns ' +
|
|
1146
1233
|
'once the execution has been REQUESTED — not once it has finished. Poll gog_sheets_datasource_describe until its ' +
|
|
1147
|
-
'dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current.'
|
|
1234
|
+
'dataExecutionStatus state is SUCCEEDED or FAILED before treating the data as current. Because it bills, it first asks ' +
|
|
1235
|
+
'the MCP host to show the user a confirmation prompt naming the spreadsheet, the query or table and the billing ' +
|
|
1236
|
+
'project; nothing runs unless they accept.' + CONFIRM_FALLBACK_DESCRIPTION;
|
|
1148
1237
|
|
|
1149
1238
|
server.registerTool('gog_sheets_datasource_add', {
|
|
1150
1239
|
description:
|
|
@@ -1162,8 +1251,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1162
1251
|
table: z.string().optional().describe('BigQuery table ID. Requires dataset; mutually exclusive with query.'),
|
|
1163
1252
|
tableProject: z.string().optional().describe('BigQuery project that owns the table (default: the billing project). Only valid with dataset/table.'),
|
|
1164
1253
|
account: accountParam,
|
|
1254
|
+
confirmToken: confirmTokenParam,
|
|
1165
1255
|
}),
|
|
1166
|
-
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
1256
|
+
}, async ({ spreadsheetId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
1167
1257
|
const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
|
|
1168
1258
|
if (query !== undefined && tableFlags) {
|
|
1169
1259
|
throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
|
|
@@ -1176,6 +1266,12 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1176
1266
|
if (tableProject) args.push(`--table-project=${tableProject}`);
|
|
1177
1267
|
if (dataset) args.push(`--dataset=${dataset}`);
|
|
1178
1268
|
if (table) args.push(`--table=${table}`);
|
|
1269
|
+
const source = query !== undefined ? { query } : { table: [tableProject ?? billingProject, dataset, table].join('.') };
|
|
1270
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1271
|
+
tool: 'gog_sheets_datasource_add', action: 'sheets.datasource-add', account, confirmToken, spreadsheetId,
|
|
1272
|
+
target: spreadsheetId, view: { billingProject, ...source },
|
|
1273
|
+
});
|
|
1274
|
+
if (confirmation) return confirmation;
|
|
1179
1275
|
return runOrDiagnose(args, { account });
|
|
1180
1276
|
});
|
|
1181
1277
|
|
|
@@ -1196,8 +1292,9 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1196
1292
|
table: z.string().optional().describe('Replacement table ID. Only valid on a table-backed source.'),
|
|
1197
1293
|
tableProject: z.string().optional().describe('Replacement project owning the table. Only valid on a table-backed source.'),
|
|
1198
1294
|
account: accountParam,
|
|
1295
|
+
confirmToken: confirmTokenParam,
|
|
1199
1296
|
}),
|
|
1200
|
-
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account }) => {
|
|
1297
|
+
}, async ({ spreadsheetId, dataSourceId, billingProject, query, dataset, table, tableProject, account, confirmToken }, ctx) => {
|
|
1201
1298
|
const tableFlags = tableProject !== undefined || dataset !== undefined || table !== undefined;
|
|
1202
1299
|
if (query !== undefined && tableFlags) {
|
|
1203
1300
|
throw new Error('query and the table fields (dataset, table, tableProject) are mutually exclusive — a data source is backed by one or the other.');
|
|
@@ -1211,6 +1308,12 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1211
1308
|
if (tableProject !== undefined) args.push(`--table-project=${tableProject}`);
|
|
1212
1309
|
if (dataset !== undefined) args.push(`--dataset=${dataset}`);
|
|
1213
1310
|
if (table !== undefined) args.push(`--table=${table}`);
|
|
1311
|
+
const changes = Object.fromEntries(Object.entries({ billingProject, query, tableProject, dataset, table }).filter(([, v]) => v !== undefined));
|
|
1312
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1313
|
+
tool: 'gog_sheets_datasource_update', action: 'sheets.datasource-update', account, confirmToken, spreadsheetId,
|
|
1314
|
+
target: `${spreadsheetId}/${dataSourceId}`, view: { dataSourceId, changes },
|
|
1315
|
+
});
|
|
1316
|
+
if (confirmation) return confirmation;
|
|
1214
1317
|
return runOrDiagnose(args, { account });
|
|
1215
1318
|
});
|
|
1216
1319
|
|
|
@@ -1240,10 +1343,19 @@ export function registerExtraSheetsTools(server: McpServer): void {
|
|
|
1240
1343
|
dataSourceId: z.string().describe('Data source ID, as reported by gog_sheets_datasource_list'),
|
|
1241
1344
|
forceRefresh: z.boolean().optional().describe('Refresh even when the previous execution failed'),
|
|
1242
1345
|
account: accountParam,
|
|
1346
|
+
confirmToken: confirmTokenParam,
|
|
1243
1347
|
}),
|
|
1244
|
-
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account }) => {
|
|
1348
|
+
}, async ({ spreadsheetId, dataSourceId, forceRefresh, account, confirmToken }, ctx) => {
|
|
1245
1349
|
const args: GogArg[] = ['sheets', 'datasource', 'refresh', pos(spreadsheetId), pos(dataSourceId)];
|
|
1246
1350
|
if (forceRefresh) args.push('--force-refresh');
|
|
1351
|
+
const described = await runOrDiagnose(['sheets', 'datasource', 'describe', pos(spreadsheetId), pos(dataSourceId)], { account });
|
|
1352
|
+
if (described.isError) return described;
|
|
1353
|
+
const confirmation = await confirmBilledExecution(ctx, {
|
|
1354
|
+
tool: 'gog_sheets_datasource_refresh', action: 'sheets.datasource-refresh', account, confirmToken, spreadsheetId,
|
|
1355
|
+
target: `${spreadsheetId}/${dataSourceId}`,
|
|
1356
|
+
view: { dataSourceId, ...dataSourceSnapshot(resultText(described)), forceRefresh: Boolean(forceRefresh) },
|
|
1357
|
+
});
|
|
1358
|
+
if (confirmation) return confirmation;
|
|
1247
1359
|
return runOrDiagnose(args, { account });
|
|
1248
1360
|
});
|
|
1249
1361
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { registerExtraSheetsTools, dataSourceSnapshot, spreadsheetTitle } from '../../src/tools/sheets-extra.js';
|
|
3
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
6
|
+
import type { ElicitRequest, ElicitResult } from '@modelcontextprotocol/server';
|
|
7
|
+
import { pos } from '../../../gogcli-mcp/src/argv.js';
|
|
8
|
+
import { resetConfirmTokenState } from '../../../gogcli-mcp/src/send-confirm-token.js';
|
|
9
|
+
|
|
10
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
11
|
+
const actual = await importOriginal<typeof lib>();
|
|
12
|
+
return { ...actual, runOrDiagnose: vi.fn() };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Fleet audit 2026-09-24 SEC-6: datasource add / update / refresh each start a
|
|
16
|
+
// BigQuery job billed to a project, so each asks first — naming the spreadsheet,
|
|
17
|
+
// what is queried and who pays.
|
|
18
|
+
|
|
19
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.clearAllMocks();
|
|
22
|
+
process.env = { ...ORIGINAL_ENV };
|
|
23
|
+
delete process.env.MCP_CONFIRM_MODE;
|
|
24
|
+
resetConfirmTokenState();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => { process.env = ORIGINAL_ENV; });
|
|
27
|
+
|
|
28
|
+
const json = (r: { content: Array<{ text?: string }> }) => JSON.parse(r.content[0]!.text as string);
|
|
29
|
+
async function prompted(answer: ElicitResult = { action: 'accept', content: { confirmed: true } }) {
|
|
30
|
+
const seen: ElicitRequest[] = [];
|
|
31
|
+
const harness = await createTestHarness(registerExtraSheetsTools, { elicitation: async (r) => { seen.push(r); return answer; } });
|
|
32
|
+
const details = () => JSON.parse(seen[0]!.params.message.split('\n').slice(1).join('\n')).details;
|
|
33
|
+
return { harness, seen, details };
|
|
34
|
+
}
|
|
35
|
+
const unprompted = () => createTestHarness(registerExtraSheetsTools);
|
|
36
|
+
|
|
37
|
+
const META = JSON.stringify({ properties: { title: 'Sales dashboard' } });
|
|
38
|
+
const DESCRIBE = JSON.stringify({ dataSource: { dataSourceId: 'ds1', spec: { bigQuery: { projectId: 'billing-1', querySpec: { rawQuery: 'SELECT 1' } } } } });
|
|
39
|
+
function stub(failing?: string) {
|
|
40
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args) => {
|
|
41
|
+
const key = args.filter((a): a is string => typeof a === 'string' && !a.startsWith('--')).join(' ');
|
|
42
|
+
if (failing && key.startsWith(failing)) return { content: [{ type: 'text', text: 'Error: not found' }], isError: true };
|
|
43
|
+
if (key === 'sheets metadata') return rawTextResult(META);
|
|
44
|
+
if (key === 'sheets datasource describe') return rawTextResult(DESCRIBE);
|
|
45
|
+
return rawTextResult('{"ok":true}');
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const callsTo = (...words: string[]) => vi.mocked(lib.runOrDiagnose).mock.calls.filter(([args]) =>
|
|
49
|
+
words.every((w, i) => args[i] === w));
|
|
50
|
+
|
|
51
|
+
const SHEET = { id: 'sid', title: 'Sales dashboard' };
|
|
52
|
+
const CASES = [
|
|
53
|
+
{
|
|
54
|
+
tool: 'gog_sheets_datasource_add',
|
|
55
|
+
args: { spreadsheetId: 'sid', billingProject: 'billing-1', dataset: 'sales', table: 'orders' },
|
|
56
|
+
action: 'sheets.datasource-add',
|
|
57
|
+
details: { spreadsheet: SHEET, billingProject: 'billing-1', table: 'billing-1.sales.orders' },
|
|
58
|
+
write: 'add',
|
|
59
|
+
reads: ['sheets metadata'],
|
|
60
|
+
changed: { table: 'refunds' },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
tool: 'gog_sheets_datasource_update',
|
|
64
|
+
args: { spreadsheetId: 'sid', dataSourceId: 'ds1', query: 'SELECT 2', billingProject: 'billing-2' },
|
|
65
|
+
action: 'sheets.datasource-update',
|
|
66
|
+
details: { spreadsheet: SHEET, dataSourceId: 'ds1', changes: { billingProject: 'billing-2', query: 'SELECT 2' } },
|
|
67
|
+
write: 'update',
|
|
68
|
+
reads: ['sheets metadata'],
|
|
69
|
+
changed: { query: 'SELECT 3' },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
tool: 'gog_sheets_datasource_refresh',
|
|
73
|
+
args: { spreadsheetId: 'sid', dataSourceId: 'ds1', forceRefresh: true },
|
|
74
|
+
action: 'sheets.datasource-refresh',
|
|
75
|
+
details: { spreadsheet: SHEET, dataSourceId: 'ds1', billingProject: 'billing-1', query: 'SELECT 1', forceRefresh: true },
|
|
76
|
+
write: 'refresh',
|
|
77
|
+
reads: ['sheets datasource describe', 'sheets metadata'],
|
|
78
|
+
changed: { forceRefresh: false },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
describe.each(CASES)('$tool', (c) => {
|
|
83
|
+
it('asks naming the spreadsheet, the source and who pays; runs once accepted', async () => {
|
|
84
|
+
stub();
|
|
85
|
+
const { harness, details, seen } = await prompted();
|
|
86
|
+
await harness.callTool(c.tool, c.args);
|
|
87
|
+
expect(seen[0]!.params.message).toMatch(/billed/);
|
|
88
|
+
expect(details()).toEqual(c.details);
|
|
89
|
+
expect(callsTo('sheets', 'metadata')[0]![0]).toEqual(['sheets', 'metadata', pos('sid'), '--select=properties.title']);
|
|
90
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('runs nothing when the user declines', async () => {
|
|
94
|
+
stub();
|
|
95
|
+
expect(json(await (await prompted({ action: 'decline' })).harness.callTool(c.tool, c.args)))
|
|
96
|
+
.toMatchObject({ cancelled: true, action: c.action });
|
|
97
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it.each(c.reads)('returns a failed read (%s) without asking or running', async (failing) => {
|
|
101
|
+
stub(failing);
|
|
102
|
+
const { harness, seen } = await prompted();
|
|
103
|
+
expect((await harness.callTool(c.tool, c.args)).isError).toBe(true);
|
|
104
|
+
expect(seen).toHaveLength(0);
|
|
105
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses a client that cannot be prompted', async () => {
|
|
109
|
+
process.env.MCP_CONFIRM_MODE = 'refuse';
|
|
110
|
+
stub();
|
|
111
|
+
const r = json(await (await unprompted()).callTool(c.tool, c.args));
|
|
112
|
+
expect(r).toMatchObject({ reason: 'confirmation-unsupported', action: c.action });
|
|
113
|
+
expect(r.note).toMatch(/Data connectors/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('token fallback: changed arguments are refused, the same call runs', async () => {
|
|
117
|
+
process.env.MCP_CONFIRM_MODE = 'ask-user';
|
|
118
|
+
stub();
|
|
119
|
+
const harness = await unprompted();
|
|
120
|
+
const p1 = json(await harness.callTool(c.tool, c.args));
|
|
121
|
+
expect(p1.preview).toEqual(c.details);
|
|
122
|
+
expect(json(await harness.callTool(c.tool, { ...c.args, ...c.changed, confirmToken: p1.confirmToken })))
|
|
123
|
+
.toMatchObject({ error: 'DRAFT_CHANGED' });
|
|
124
|
+
await harness.callTool(c.tool, { ...c.args, confirmToken: p1.confirmToken });
|
|
125
|
+
expect(callsTo('sheets', 'datasource', c.write)).toHaveLength(1);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('a query-backed add previews the SQL', async () => {
|
|
130
|
+
stub();
|
|
131
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
132
|
+
await harness.callTool('gog_sheets_datasource_add', { spreadsheetId: 'sid', billingProject: 'b', query: 'SELECT 9', });
|
|
133
|
+
expect(details()).toEqual({ spreadsheet: SHEET, billingProject: 'b', query: 'SELECT 9' });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('a table in another project names that project', async () => {
|
|
137
|
+
stub();
|
|
138
|
+
const { harness, details } = await prompted({ action: 'decline' });
|
|
139
|
+
await harness.callTool('gog_sheets_datasource_add', { spreadsheetId: 'sid', billingProject: 'b', tableProject: 'p', dataset: 'd', table: 't' });
|
|
140
|
+
expect(details().table).toBe('p.d.t');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('parsers', () => {
|
|
144
|
+
it('spreadsheetTitle reads either shape and tolerates unreadable output', () => {
|
|
145
|
+
expect(spreadsheetTitle(JSON.stringify({ spreadsheet: { properties: { title: 'A' } } }))).toBe('A');
|
|
146
|
+
expect(spreadsheetTitle(JSON.stringify({ properties: { title: 7 } }))).toBeUndefined();
|
|
147
|
+
expect(spreadsheetTitle('null')).toBeUndefined();
|
|
148
|
+
expect(spreadsheetTitle(undefined)).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('dataSourceSnapshot names a table source, a bare spec, and nothing for unreadable output', () => {
|
|
152
|
+
expect(dataSourceSnapshot(JSON.stringify({ spec: { bigQuery: { tableSpec: { tableProjectId: 'p', datasetId: 'd', tableId: 't' } } } })))
|
|
153
|
+
.toEqual({ table: 'p.d.t' });
|
|
154
|
+
expect(dataSourceSnapshot(JSON.stringify({ dataSource: { spec: { bigQuery: { projectId: 7 } } } }))).toEqual({});
|
|
155
|
+
expect(dataSourceSnapshot('null')).toEqual({});
|
|
156
|
+
expect(dataSourceSnapshot('nope')).toEqual({});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -15,7 +15,10 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
15
15
|
};
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
// The billed datasource tools ask first (sheets-extra-gates.test.ts covers the prompts); here the user accepts.
|
|
19
|
+
const setupHandlers = () => createTestHarness(registerExtraSheetsTools, {
|
|
20
|
+
elicitation: async () => ({ action: 'accept', content: { confirmed: true } }),
|
|
21
|
+
});
|
|
19
22
|
|
|
20
23
|
beforeEach(() => vi.clearAllMocks());
|
|
21
24
|
|