gogcli-mcp 2.0.11 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  });