gogcli-mcp 2.27.1 → 2.29.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/README.md +3 -3
- package/SKILL.md +2 -2
- package/dist/index.js +171 -15
- package/dist/lib.js +172 -16
- package/manifest.json +9 -5
- package/mint.yaml +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/runner.ts +1 -1
- package/src/tools/calendar.ts +32 -2
- package/src/tools/chat.ts +36 -1
- package/src/tools/drive.ts +48 -7
- package/src/tools/utils.ts +85 -4
- package/src/worker.ts +1 -1
- package/tests/tools/calendar.test.ts +44 -20
- package/tests/tools/chat.test.ts +61 -1
- package/tests/tools/drive.test.ts +81 -13
- package/tests/tools/utils.test.ts +141 -0
package/src/tools/utils.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
-
import { errorResult, rawTextResult } from '@chrischall/mcp-utils';
|
|
4
|
+
import { errorResult, rawTextResult, minifiedResult, stripMediaUrls } from '@chrischall/mcp-utils';
|
|
5
5
|
import { run, isRunnerTransportError } from '../runner.js';
|
|
6
6
|
import type { GogArg, RunnerFailureKind } from '../runner.js';
|
|
7
7
|
import { normalizeTimestamps } from '../timestamps.js';
|
|
@@ -342,9 +342,89 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// gog pretty-prints its `--json` output, and that indentation is roughly a
|
|
346
|
+
// fifth of a large response while carrying no information a caller reads —
|
|
347
|
+
// 18.6% of a 19 KB `drive ls`, 38% of a small deeply-nested one. Dropping it is
|
|
348
|
+
// the largest token saving available to a passthrough wrapper, which is what
|
|
349
|
+
// this repo is: a tool's output IS gog's JSON, so there is no projection to
|
|
350
|
+
// make instead.
|
|
351
|
+
//
|
|
352
|
+
// Only FORMATTING whitespace goes. mcp-utils' `minifiedResult` is
|
|
353
|
+
// `JSON.stringify` with no indent, which touches neither whitespace INSIDE a
|
|
354
|
+
// value (the blank line between paragraphs of a mail body, the indentation of a
|
|
355
|
+
// quoted block) nor key ORDER — gog emits `nextPageToken` before its data array,
|
|
356
|
+
// and a truncated read still has to see it. A regex over the serialised text
|
|
357
|
+
// would corrupt exactly the payloads this exists to shrink.
|
|
358
|
+
//
|
|
359
|
+
// Anything that is not JSON passes through byte-for-byte: `gog auth list` is
|
|
360
|
+
// plain text, an empty body is legal, and a mangled non-answer is worse than a
|
|
361
|
+
// large one. The guard mirrors normalizeTimestamps' so the two agree on what
|
|
362
|
+
// counts as JSON.
|
|
363
|
+
function minifiedOrRawText(text: string, stripMedia = false): CallToolResult {
|
|
364
|
+
const trimmed = text.trim();
|
|
365
|
+
if (trimmed === '' || !/^[[{]/.test(trimmed)) return rawTextResult(text);
|
|
366
|
+
try {
|
|
367
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
368
|
+
return minifiedResult(stripMedia ? stripMediaUrls(parsed) : parsed);
|
|
369
|
+
} catch {
|
|
370
|
+
return rawTextResult(text);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// THE SECOND COMPACT MECHANISM. `--fields` below is a projection GOOGLE
|
|
375
|
+
// performs; `stripMedia` is one performed here, for the tools whose gog
|
|
376
|
+
// subcommand accepts no mask at all — `gog schema --json` lists only nine that
|
|
377
|
+
// do. Both surface through the same `view` vocabulary, so a caller never has to
|
|
378
|
+
// know which lever a given tool pulls.
|
|
379
|
+
//
|
|
380
|
+
// It drops `thumbnailLink` and friends: URLs a model cannot see, cannot fetch,
|
|
381
|
+
// and would not benefit from if it could. Worth 30-33% on every Drive
|
|
382
|
+
// file-metadata read, measured live. It is deliberately OPT-IN per tool, per
|
|
383
|
+
// the rule that a tool whose PRODUCT is the image must never strip — the tool's
|
|
384
|
+
// own name is the test.
|
|
385
|
+
//
|
|
386
|
+
// A Google field mask (`--fields`) is the other `compact` rung's projection,
|
|
387
|
+
// and it is applied UPSTREAM — inside the Google API, not here. Two consequences shape
|
|
388
|
+
// this function.
|
|
389
|
+
//
|
|
390
|
+
// First, the mask MUST name the response envelope's paging field. A mask of
|
|
391
|
+
// `files(...)` alone drops `nextPageToken` from the payload, so a compact read
|
|
392
|
+
// returns page one with an empty cursor and reads as "there is nothing more" —
|
|
393
|
+
// silent truncation, and the exact false negative this repo's pagination guards
|
|
394
|
+
// exist to prevent. Verified live against gog 0.39.0 on both drive and
|
|
395
|
+
// calendar; the per-tool masks are asserted to start with the paging field.
|
|
396
|
+
//
|
|
397
|
+
// Second, a mask this wrapper gets wrong is a hard 400 from Google rather than
|
|
398
|
+
// a thin record, so `projectOrRaw`'s local try/fallback cannot apply. This is
|
|
399
|
+
// its analogue across the network: a rejected mask retries UNPROJECTED and says
|
|
400
|
+
// why on stderr, because a large correct answer beats a failed tool call. Only
|
|
401
|
+
// a rejected mask is retried — a missing file is not a bad mask, and retrying
|
|
402
|
+
// it would just spend a second call to fail the same way.
|
|
403
|
+
function isRejectedFieldMask(err: unknown): boolean {
|
|
404
|
+
return /invalidParameter|invalid field selection/i.test(String(err));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function runProjected(
|
|
408
|
+
args: GogArg[],
|
|
409
|
+
options: { account?: string; lossless?: boolean; fieldsMask?: string },
|
|
410
|
+
): Promise<string> {
|
|
411
|
+
const { fieldsMask } = options;
|
|
412
|
+
if (!fieldsMask) return run(args, options);
|
|
413
|
+
try {
|
|
414
|
+
return await run([...args, `--fields=${fieldsMask}`], options);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
if (!isRejectedFieldMask(err)) throw err;
|
|
417
|
+
// stderr, never stdout: stdout is the JSON-RPC channel.
|
|
418
|
+
process.stderr.write(
|
|
419
|
+
`gogcli-mcp: Google rejected the compact field mask (${fieldsMask}); retrying unprojected\n`,
|
|
420
|
+
);
|
|
421
|
+
return run(args, options);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
345
425
|
export async function runOrDiagnose(
|
|
346
426
|
args: GogArg[],
|
|
347
|
-
options: { account?: string; lossless?: boolean },
|
|
427
|
+
options: { account?: string; lossless?: boolean; fieldsMask?: string; stripMedia?: boolean },
|
|
348
428
|
): Promise<CallToolResult> {
|
|
349
429
|
try {
|
|
350
430
|
// The single seam every tool's output passes through. Normalizing here —
|
|
@@ -357,11 +437,12 @@ export async function runOrDiagnose(
|
|
|
357
437
|
// `--pretty` formatting, so the one tool you reach for when you need ground
|
|
358
438
|
// truth would stop telling it. Losslessness wins over presentation there —
|
|
359
439
|
// the friendlier views of the same data are already normalized.
|
|
360
|
-
const raw = await
|
|
440
|
+
const raw = await runProjected(args, options);
|
|
361
441
|
// Same seam, same reason as normalizeTimestamps: doing this per call site
|
|
362
442
|
// would let one paginated tool forget and go on reporting a spent cursor as
|
|
363
443
|
// if it were a live one. `lossless` opts the raw dumps out of both.
|
|
364
|
-
|
|
444
|
+
if (options.lossless) return rawTextResult(raw);
|
|
445
|
+
return minifiedOrRawText(stripConsumedPageToken(normalizeTimestamps(raw)), options.stripMedia);
|
|
365
446
|
} catch (err) {
|
|
366
447
|
return diagnose(err);
|
|
367
448
|
}
|
package/src/worker.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
|
|
|
38
38
|
// connector with all ~360 tools at once. Add whichever paths you want as separate
|
|
39
39
|
// connectors in claude.ai (each authorizes with the same connector key).
|
|
40
40
|
|
|
41
|
-
const VERSION = '2.
|
|
41
|
+
const VERSION = '2.29.0'; // x-release-please-version
|
|
42
42
|
|
|
43
43
|
// Build an McpAgent subclass whose init() registers `registrars` onto its server,
|
|
44
44
|
// each handler wrapped in the ALS scope carrying the per-session Fly executor.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { registerCalendarTools } from '../../src/tools/calendar.js';
|
|
2
|
+
import { registerCalendarTools, CALENDAR_EVENTS_COMPACT_FIELDS } from '../../src/tools/calendar.js';
|
|
3
3
|
import * as runner from '../../src/runner.js';
|
|
4
4
|
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
5
|
|
|
@@ -7,6 +7,13 @@ vi.mock('../../src/runner.js');
|
|
|
7
7
|
|
|
8
8
|
const setupHandlers = () => createTestHarness(registerCalendarTools);
|
|
9
9
|
|
|
10
|
+
// gog_calendar_events answers in the compact rung by default, so every call
|
|
11
|
+
// carries the mask. These keep the window-flag tests below about window flags
|
|
12
|
+
// while still asserting the shipped default rather than an opted-out view.
|
|
13
|
+
const evArgs = (...extra: string[]) =>
|
|
14
|
+
['calendar', 'events', ...extra, `--fields=${CALENDAR_EVENTS_COMPACT_FIELDS}`];
|
|
15
|
+
const evOpts = { account: undefined, fieldsMask: CALENDAR_EVENTS_COMPACT_FIELDS };
|
|
16
|
+
|
|
10
17
|
beforeEach(() => vi.clearAllMocks());
|
|
11
18
|
|
|
12
19
|
describe('gog_calendar_events', () => {
|
|
@@ -14,7 +21,36 @@ describe('gog_calendar_events', () => {
|
|
|
14
21
|
vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
|
|
15
22
|
const harness = await setupHandlers();
|
|
16
23
|
await harness.callTool('gog_calendar_events', {});
|
|
17
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
24
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs(), evOpts);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// The cheap rung is the default. Measured at 66% smaller on a 25-event
|
|
28
|
+
// window — the largest saving available in this repo, because a calendar
|
|
29
|
+
// event's description and attendee list dwarf the fields a caller acts on.
|
|
30
|
+
it('sends no mask for the full view', async () => {
|
|
31
|
+
vi.mocked(runner.run).mockResolvedValue('{"items":[]}');
|
|
32
|
+
const harness = await setupHandlers();
|
|
33
|
+
await harness.callTool('gog_calendar_events', { view: 'full' });
|
|
34
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
35
|
+
['calendar', 'events'],
|
|
36
|
+
{ account: undefined, fieldsMask: undefined },
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// A Google field mask drops nextPageToken from the envelope, so a compact
|
|
41
|
+
// read returns page one with an EMPTY cursor. This tool's own description
|
|
42
|
+
// warns that a wide range is usually incomplete and to page until the cursor
|
|
43
|
+
// is gone — a mask that silently removes the cursor would turn that guidance
|
|
44
|
+
// into a guarantee of the wrong answer. Verified live against gog 0.39.0.
|
|
45
|
+
it('names the paging field in the mask', () => {
|
|
46
|
+
expect(CALENDAR_EVENTS_COMPACT_FIELDS).toMatch(/^nextPageToken,/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects a view rung this tool does not honour', async () => {
|
|
50
|
+
const harness = await setupHandlers();
|
|
51
|
+
const result = await harness.callTool('gog_calendar_events', { view: 'raw' });
|
|
52
|
+
expect(result.isError).toBe(true);
|
|
53
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
18
54
|
});
|
|
19
55
|
|
|
20
56
|
// The window flags are pinned as SEPARATE cases on purpose: gog >= 0.36.0
|
|
@@ -31,54 +67,42 @@ describe('gog_calendar_events', () => {
|
|
|
31
67
|
query: 'standup',
|
|
32
68
|
all: true,
|
|
33
69
|
});
|
|
34
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
35
|
-
['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'],
|
|
36
|
-
{ account: undefined },
|
|
37
|
-
);
|
|
70
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'), evOpts);
|
|
38
71
|
});
|
|
39
72
|
|
|
40
73
|
it('appends --today on its own', async () => {
|
|
41
74
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
42
75
|
const harness = await setupHandlers();
|
|
43
76
|
await harness.callTool('gog_calendar_events', { today: true });
|
|
44
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
77
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('--today'), evOpts);
|
|
45
78
|
});
|
|
46
79
|
|
|
47
80
|
it('anchors --days at --from when both are given', async () => {
|
|
48
81
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
49
82
|
const harness = await setupHandlers();
|
|
50
83
|
await harness.callTool('gog_calendar_events', { from: '2026-09-25', days: 5 });
|
|
51
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
52
|
-
['calendar', 'events', '--from=2026-09-25', '--days=5'],
|
|
53
|
-
{ account: undefined },
|
|
54
|
-
);
|
|
84
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('--from=2026-09-25', '--days=5'), evOpts);
|
|
55
85
|
});
|
|
56
86
|
|
|
57
87
|
it('passes --days alone as a today-anchored window', async () => {
|
|
58
88
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
59
89
|
const harness = await setupHandlers();
|
|
60
90
|
await harness.callTool('gog_calendar_events', { days: 7 });
|
|
61
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
91
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('--days=7'), evOpts);
|
|
62
92
|
});
|
|
63
93
|
|
|
64
94
|
it('repeats --event-types for each requested type', async () => {
|
|
65
95
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
66
96
|
const harness = await setupHandlers();
|
|
67
97
|
await harness.callTool('gog_calendar_events', { eventTypes: ['default', 'out-of-office'] });
|
|
68
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
69
|
-
['calendar', 'events', '--event-types=default', '--event-types=out-of-office'],
|
|
70
|
-
{ account: undefined },
|
|
71
|
-
);
|
|
98
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('--event-types=default', '--event-types=out-of-office'), evOpts);
|
|
72
99
|
});
|
|
73
100
|
|
|
74
101
|
it('appends --timezone when provided', async () => {
|
|
75
102
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
76
103
|
const harness = await setupHandlers();
|
|
77
104
|
await harness.callTool('gog_calendar_events', { timezone: 'America/New_York' });
|
|
78
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
79
|
-
['calendar', 'events', '--timezone=America/New_York'],
|
|
80
|
-
{ account: undefined },
|
|
81
|
-
);
|
|
105
|
+
expect(runner.run).toHaveBeenCalledWith(evArgs('--timezone=America/New_York'), evOpts);
|
|
82
106
|
});
|
|
83
107
|
|
|
84
108
|
it('returns error text on failure', async () => {
|
package/tests/tools/chat.test.ts
CHANGED
|
@@ -46,7 +46,7 @@ describe('gog_chat_spaces_list', () => {
|
|
|
46
46
|
return undefined as never;
|
|
47
47
|
});
|
|
48
48
|
registerChatTools(server);
|
|
49
|
-
expect(configs.size).toBe(
|
|
49
|
+
expect(configs.size).toBe(13);
|
|
50
50
|
for (const [name, config] of configs) {
|
|
51
51
|
expect(config.description, name).toMatch(/consumer accounts|Workspace/i);
|
|
52
52
|
}
|
|
@@ -132,6 +132,66 @@ describe('gog_chat_messages_list', () => {
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
describe('gog_chat_messages_search', () => {
|
|
136
|
+
it('searches across every space the account can see', async () => {
|
|
137
|
+
vi.mocked(runner.run).mockResolvedValue('{"results":[]}');
|
|
138
|
+
const harness = await setupHandlers();
|
|
139
|
+
await harness.callTool('gog_chat_messages_search', { query: 'project decision' });
|
|
140
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
141
|
+
['chat', 'messages', 'search', 'project decision'],
|
|
142
|
+
{ account: undefined },
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('passes view, markup, order and pagination flags', async () => {
|
|
147
|
+
vi.mocked(runner.run).mockResolvedValue('{"results":[]}');
|
|
148
|
+
const harness = await setupHandlers();
|
|
149
|
+
await harness.callTool('gog_chat_messages_search', {
|
|
150
|
+
query: 'from:alice@example.com budget',
|
|
151
|
+
view: 'full',
|
|
152
|
+
markup: 'markdown',
|
|
153
|
+
order: 'create_time desc',
|
|
154
|
+
max: 100,
|
|
155
|
+
pageToken: 'tok',
|
|
156
|
+
all: true,
|
|
157
|
+
});
|
|
158
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
159
|
+
['chat', 'messages', 'search', 'from:alice@example.com budget', '--order=create_time desc',
|
|
160
|
+
'--view=full', '--markup=markdown', '--max=100', '--page=tok', '--all'],
|
|
161
|
+
{ account: undefined },
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Chat's own ordering vocabulary is snake_case here and camelCase in
|
|
166
|
+
// `messages list`; a model that copies the list tool's "createTime desc"
|
|
167
|
+
// would get a gog error at runtime, so the enum has to reject it here.
|
|
168
|
+
it('rejects an order value search does not accept', async () => {
|
|
169
|
+
const harness = await setupHandlers();
|
|
170
|
+
const result = await harness.callTool('gog_chat_messages_search', {
|
|
171
|
+
query: 'x', order: 'createTime desc',
|
|
172
|
+
});
|
|
173
|
+
expect(result.isError).toBe(true);
|
|
174
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Chat caps a search page at 100; `messages list` allows more. Letting the
|
|
178
|
+
// larger number through would surface as a gog error on a call the tool
|
|
179
|
+
// already had enough information to refuse.
|
|
180
|
+
it('rejects a page size above Chat search\'s cap of 100', async () => {
|
|
181
|
+
const harness = await setupHandlers();
|
|
182
|
+
const result = await harness.callTool('gog_chat_messages_search', { query: 'x', max: 250 });
|
|
183
|
+
expect(result.isError).toBe(true);
|
|
184
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns error text on failure', async () => {
|
|
188
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('Search failed'));
|
|
189
|
+
const harness = await setupHandlers();
|
|
190
|
+
const result = await harness.callTool('gog_chat_messages_search', { query: 'x' });
|
|
191
|
+
expect(result.content[0].text).toBe('Error: Search failed');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
135
195
|
describe('gog_chat_messages_send', () => {
|
|
136
196
|
it('sends text to a space', async () => {
|
|
137
197
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { registerDriveTools } from '../../src/tools/drive.js';
|
|
2
|
+
import { registerDriveTools, DRIVE_LS_COMPACT_FIELDS } from '../../src/tools/drive.js';
|
|
3
3
|
import * as runner from '../../src/runner.js';
|
|
4
4
|
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
5
5
|
|
|
@@ -7,6 +7,13 @@ vi.mock('../../src/runner.js');
|
|
|
7
7
|
|
|
8
8
|
const setupHandlers = () => createTestHarness(registerDriveTools);
|
|
9
9
|
|
|
10
|
+
// gog_drive_ls answers in the compact rung by default, so every call carries
|
|
11
|
+
// the mask. These keep the flag-mapping tests below about flag mapping while
|
|
12
|
+
// still asserting the shipped default rather than a view they opted out of.
|
|
13
|
+
const lsArgs = (...extra: string[]) =>
|
|
14
|
+
['drive', 'ls', ...extra, `--fields=${DRIVE_LS_COMPACT_FIELDS}`];
|
|
15
|
+
const lsOpts = { account: undefined, fieldsMask: DRIVE_LS_COMPACT_FIELDS };
|
|
16
|
+
|
|
10
17
|
beforeEach(() => vi.clearAllMocks());
|
|
11
18
|
|
|
12
19
|
describe('gog_drive_ls', () => {
|
|
@@ -14,14 +21,48 @@ describe('gog_drive_ls', () => {
|
|
|
14
21
|
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
15
22
|
const harness = await setupHandlers();
|
|
16
23
|
await harness.callTool('gog_drive_ls', {});
|
|
17
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
24
|
+
expect(runner.run).toHaveBeenCalledWith(lsArgs(), lsOpts);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Efficiency is not something a caller should have to ask for: the cheap rung
|
|
28
|
+
// is the default. Measured at 48% smaller on a 25-row listing.
|
|
29
|
+
it('defaults to the compact view, applying the field mask', async () => {
|
|
30
|
+
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
31
|
+
const harness = await setupHandlers();
|
|
32
|
+
await harness.callTool('gog_drive_ls', {});
|
|
33
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
34
|
+
['drive', 'ls', `--fields=${DRIVE_LS_COMPACT_FIELDS}`],
|
|
35
|
+
lsOpts,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('sends no mask for the full view', async () => {
|
|
40
|
+
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
41
|
+
const harness = await setupHandlers();
|
|
42
|
+
await harness.callTool('gog_drive_ls', { view: 'full' });
|
|
43
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'ls'], { account: undefined, fieldsMask: undefined });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// THE BUG THIS ALMOST SHIPPED: a Google field mask drops nextPageToken from
|
|
47
|
+
// the envelope, so a compact read returns page one with an EMPTY cursor and
|
|
48
|
+
// reads as "no more results" — silent truncation, verified live against gog
|
|
49
|
+
// 0.39.0. Naming the paging field in the mask is what brings it back.
|
|
50
|
+
it('names the paging field in the mask', () => {
|
|
51
|
+
expect(DRIVE_LS_COMPACT_FIELDS).toMatch(/^nextPageToken,/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects a view rung this tool does not honour', async () => {
|
|
55
|
+
const harness = await setupHandlers();
|
|
56
|
+
const result = await harness.callTool('gog_drive_ls', { view: 'raw' });
|
|
57
|
+
expect(result.isError).toBe(true);
|
|
58
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
18
59
|
});
|
|
19
60
|
|
|
20
61
|
it('passes folderId as --parent flag', async () => {
|
|
21
62
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
22
63
|
const harness = await setupHandlers();
|
|
23
64
|
await harness.callTool('gog_drive_ls', { folderId: 'folder1' });
|
|
24
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
65
|
+
expect(runner.run).toHaveBeenCalledWith(lsArgs('--parent=folder1'), lsOpts);
|
|
25
66
|
});
|
|
26
67
|
|
|
27
68
|
it('supports max, page, query, and allDrives flags', async () => {
|
|
@@ -34,8 +75,8 @@ describe('gog_drive_ls', () => {
|
|
|
34
75
|
query: "name contains 'x'",
|
|
35
76
|
});
|
|
36
77
|
expect(runner.run).toHaveBeenCalledWith(
|
|
37
|
-
|
|
38
|
-
|
|
78
|
+
lsArgs('--parent=folder1', '--max=50', '--page=tok', "--query=name contains 'x'"),
|
|
79
|
+
lsOpts,
|
|
39
80
|
);
|
|
40
81
|
});
|
|
41
82
|
|
|
@@ -43,14 +84,14 @@ describe('gog_drive_ls', () => {
|
|
|
43
84
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
44
85
|
const harness = await setupHandlers();
|
|
45
86
|
await harness.callTool('gog_drive_ls', { allDrives: false });
|
|
46
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
87
|
+
expect(runner.run).toHaveBeenCalledWith(lsArgs('--no-all-drives'), lsOpts);
|
|
47
88
|
});
|
|
48
89
|
|
|
49
90
|
it('omits all-drives flag when allDrives is true (default)', async () => {
|
|
50
91
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
51
92
|
const harness = await setupHandlers();
|
|
52
93
|
await harness.callTool('gog_drive_ls', { allDrives: true });
|
|
53
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
94
|
+
expect(runner.run).toHaveBeenCalledWith(lsArgs(), lsOpts);
|
|
54
95
|
});
|
|
55
96
|
|
|
56
97
|
it('returns error text on failure', async () => {
|
|
@@ -62,11 +103,27 @@ describe('gog_drive_ls', () => {
|
|
|
62
103
|
});
|
|
63
104
|
|
|
64
105
|
describe('gog_drive_search', () => {
|
|
65
|
-
|
|
106
|
+
// drive search accepts NO field mask — `gog schema --json` lists only nine
|
|
107
|
+
// commands that do, and this is not one — so the local media strip is the
|
|
108
|
+
// only projection available to it. Measured 27.8% end to end.
|
|
109
|
+
it('calls run with query, defaulting to the compact view', async () => {
|
|
66
110
|
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
67
111
|
const harness = await setupHandlers();
|
|
68
112
|
await harness.callTool('gog_drive_search', { query: 'budget' });
|
|
69
|
-
expect(runner.run).toHaveBeenCalledWith(
|
|
113
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
114
|
+
['drive', 'search', 'budget'],
|
|
115
|
+
{ account: undefined, stripMedia: true },
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('keeps everything for the full view', async () => {
|
|
120
|
+
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
121
|
+
const harness = await setupHandlers();
|
|
122
|
+
await harness.callTool('gog_drive_search', { query: 'budget', view: 'full' });
|
|
123
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
124
|
+
['drive', 'search', 'budget'],
|
|
125
|
+
{ account: undefined, stripMedia: false },
|
|
126
|
+
);
|
|
70
127
|
});
|
|
71
128
|
|
|
72
129
|
it('returns error text on failure', async () => {
|
|
@@ -78,13 +135,24 @@ describe('gog_drive_search', () => {
|
|
|
78
135
|
});
|
|
79
136
|
|
|
80
137
|
describe('gog_drive_get', () => {
|
|
81
|
-
|
|
82
|
-
|
|
138
|
+
// Excluded from the --fields work because a mask saved only 7% there: its
|
|
139
|
+
// default field set is already narrow. The media strip saves 27.5% on the
|
|
140
|
+
// same payload end to end, which clears the bar a parameter has to clear.
|
|
141
|
+
it('calls run with fileId, defaulting to the compact view', async () => {
|
|
142
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
143
|
+
const harness = await setupHandlers();
|
|
144
|
+
await harness.callTool('gog_drive_get', { fileId: 'f1' });
|
|
145
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps everything for the full view', async () => {
|
|
149
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
83
150
|
const harness = await setupHandlers();
|
|
84
|
-
await harness.callTool('gog_drive_get', { fileId: '
|
|
85
|
-
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', '
|
|
151
|
+
await harness.callTool('gog_drive_get', { fileId: 'f1', view: 'full' });
|
|
152
|
+
expect(runner.run).toHaveBeenCalledWith(['drive', 'get', 'f1'], { account: undefined, stripMedia: false });
|
|
86
153
|
});
|
|
87
154
|
|
|
155
|
+
|
|
88
156
|
it('returns error text on failure', async () => {
|
|
89
157
|
vi.mocked(runner.run).mockRejectedValue(new Error('Not found'));
|
|
90
158
|
const harness = await setupHandlers();
|
|
@@ -123,6 +123,147 @@ describe('runOrDiagnose', () => {
|
|
|
123
123
|
expect(parsed.internalDateDisplay).toBeDefined();
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
// Formatting whitespace is roughly a fifth of a large gog response and
|
|
127
|
+
// carries no information: gog pretty-prints its --json output, and nothing
|
|
128
|
+
// downstream reads the indent. Measured at 18.6% of a 19 KB `drive ls`.
|
|
129
|
+
it('minifies gog\'s pretty-printed JSON on the normal path', async () => {
|
|
130
|
+
vi.mocked(runner.run).mockResolvedValue('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}');
|
|
131
|
+
const result = await runOrDiagnose(['drive', 'ls'], {});
|
|
132
|
+
expect(result.content[0].text).toBe('{"a":1,"b":[2,3]}');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Only FORMATTING whitespace goes. Whitespace inside a value is content —
|
|
136
|
+
// the blank line between paragraphs of a mail body — and JSON.stringify
|
|
137
|
+
// leaves every byte of it alone. A regex over the serialised text would
|
|
138
|
+
// corrupt exactly the payloads this is meant to shrink.
|
|
139
|
+
it('preserves whitespace INSIDE string values', async () => {
|
|
140
|
+
const body = 'Hi,\n\n indented quote\n\nthanks';
|
|
141
|
+
vi.mocked(runner.run).mockResolvedValue(JSON.stringify({ body }, null, 2));
|
|
142
|
+
const result = await runOrDiagnose(['gmail', 'get'], {});
|
|
143
|
+
expect(JSON.parse(result.content[0].text as string).body).toBe(body);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ofw-mcp emits paging state before its data array so a truncated read still
|
|
147
|
+
// sees it; the same reasoning applies to gog's nextPageToken. JSON.stringify
|
|
148
|
+
// preserves insertion order, so minifying must not reorder anything.
|
|
149
|
+
it('preserves key order', async () => {
|
|
150
|
+
vi.mocked(runner.run).mockResolvedValue('{\n "nextPageToken": "t",\n "files": []\n}');
|
|
151
|
+
const result = await runOrDiagnose(['drive', 'ls'], {});
|
|
152
|
+
expect(result.content[0].text).toBe('{"nextPageToken":"t","files":[]}');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// gog does not always answer in JSON — `gog auth list` is plain text, and an
|
|
156
|
+
// empty body is legal. Minification must pass anything unparseable through
|
|
157
|
+
// untouched rather than mangling it or throwing.
|
|
158
|
+
it('passes non-JSON output through untouched', async () => {
|
|
159
|
+
for (const text of ['user@gmail.com\nother@gmail.com', '', ' ', 'not json {']) {
|
|
160
|
+
vi.mocked(runner.run).mockResolvedValue(text);
|
|
161
|
+
const result = await runOrDiagnose(['auth', 'list'], {});
|
|
162
|
+
expect(result.content[0].text).toBe(text);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Brace-prefixed but unparseable — a truncated response from a killed gog, or
|
|
167
|
+
// a JSON prelude followed by garbage. It passes the `^[[{]` guard and then
|
|
168
|
+
// fails JSON.parse, and the only safe answer is the original bytes: a caller
|
|
169
|
+
// debugging a malformed payload needs to see what actually arrived.
|
|
170
|
+
//
|
|
171
|
+
// This path IS reachable through other suites today (gog_docs_structure feeds
|
|
172
|
+
// exactly this shape), but incidental coverage is not the same as a pinned
|
|
173
|
+
// behaviour — change that unrelated fixture and this branch goes dark, and
|
|
174
|
+
// the failure surfaces on whatever PR touched the fixture.
|
|
175
|
+
it('passes brace-prefixed but unparseable output through untouched', async () => {
|
|
176
|
+
for (const text of ['{"truncated": ', '[{"a":1},', '{not: json}']) {
|
|
177
|
+
vi.mocked(runner.run).mockResolvedValue(text);
|
|
178
|
+
const result = await runOrDiagnose(['drive', 'ls'], {});
|
|
179
|
+
expect(result.content[0].text).toBe(text);
|
|
180
|
+
expect(result.isError).toBeUndefined();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// The lossless dumps are the rung a person reaches for when a payload is not
|
|
185
|
+
// what they expected, and indentation is most of what makes an unfamiliar
|
|
186
|
+
// shape legible — the same asymmetry mcp-utils' viewResult applies to `raw`.
|
|
187
|
+
it('does NOT minify a lossless response', async () => {
|
|
188
|
+
const raw = '{\n "id": "m1"\n}';
|
|
189
|
+
vi.mocked(runner.run).mockResolvedValue(raw);
|
|
190
|
+
const result = await runOrDiagnose(['gmail', 'raw', 'm1'], { lossless: true });
|
|
191
|
+
expect(result.content[0].text).toBe(raw);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// A Google field mask is applied UPSTREAM, inside the API, so a mask this
|
|
195
|
+
// wrapper gets wrong is a hard 400 rather than a thin record. That makes the
|
|
196
|
+
// fallback the thing that keeps compact-by-default survivable: the same role
|
|
197
|
+
// mcp-utils' projectOrRaw plays for a projection done locally.
|
|
198
|
+
it('applies a compact field mask when one is given', async () => {
|
|
199
|
+
vi.mocked(runner.run).mockResolvedValue('{"files":[]}');
|
|
200
|
+
await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'nextPageToken,files(id)' });
|
|
201
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
202
|
+
['drive', 'ls', '--fields=nextPageToken,files(id)'],
|
|
203
|
+
expect.objectContaining({ fieldsMask: 'nextPageToken,files(id)' }),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('retries UNPROJECTED when Google rejects the mask', async () => {
|
|
208
|
+
vi.mocked(runner.run)
|
|
209
|
+
.mockRejectedValueOnce(new Error('Google API error (400 invalidParameter): Invalid field selection id'))
|
|
210
|
+
.mockResolvedValueOnce('{"files":[{"id":"f1"}]}');
|
|
211
|
+
const result = await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'files(bogus)' });
|
|
212
|
+
expect(runner.run).toHaveBeenNthCalledWith(1, ['drive', 'ls', '--fields=files(bogus)'], expect.anything());
|
|
213
|
+
expect(runner.run).toHaveBeenNthCalledWith(2, ['drive', 'ls'], expect.anything());
|
|
214
|
+
// The caller gets the whole payload, not an error: a projection that trips
|
|
215
|
+
// returns everything rather than taking the tool call down.
|
|
216
|
+
expect(result.isError).toBeUndefined();
|
|
217
|
+
expect(result.content[0].text).toBe('{"files":[{"id":"f1"}]}');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The fallback must not swallow real failures — a missing file is not a bad
|
|
221
|
+
// mask, and retrying it would just spend a second call to fail identically.
|
|
222
|
+
it('does NOT retry an error that is not a rejected mask', async () => {
|
|
223
|
+
vi.mocked(runner.run)
|
|
224
|
+
.mockRejectedValueOnce(new Error('File not found'))
|
|
225
|
+
.mockResolvedValueOnce('user@gmail.com');
|
|
226
|
+
const result = await runOrDiagnose(['drive', 'ls'], { fieldsMask: 'files(id)' });
|
|
227
|
+
expect(result.isError).toBe(true);
|
|
228
|
+
expect(runner.run).not.toHaveBeenNthCalledWith(2, ['drive', 'ls'], expect.anything());
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// The second compact mechanism. `--fields` is a projection Google performs;
|
|
232
|
+
// this one is performed here, for the tools whose gog subcommand accepts no
|
|
233
|
+
// mask at all. Both surface through the same `view` vocabulary.
|
|
234
|
+
it('strips media keys when asked, and minifies the result', async () => {
|
|
235
|
+
vi.mocked(runner.run).mockResolvedValue(
|
|
236
|
+
'{\n "id": "f1",\n "thumbnailLink": "https://lh3.googleusercontent.com/x=s220",\n "webViewLink": "https://docs.google.com/d/f1"\n}',
|
|
237
|
+
);
|
|
238
|
+
const result = await runOrDiagnose(['drive', 'get', 'f1'], { stripMedia: true });
|
|
239
|
+
expect(result.content[0].text).toBe('{"id":"f1","webViewLink":"https://docs.google.com/d/f1"}');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// webViewLink is the one URL a caller acts on and it sits in the same object
|
|
243
|
+
// as thumbnailLink. Stripping it would empty the response of the useful half.
|
|
244
|
+
it('keeps webViewLink and hasThumbnail while stripping the thumbnail', async () => {
|
|
245
|
+
vi.mocked(runner.run).mockResolvedValue(
|
|
246
|
+
JSON.stringify({ files: [{ id: 'f1', hasThumbnail: false, thumbnailLink: 'https://x/y=s220', webViewLink: 'https://docs/f1' }] }),
|
|
247
|
+
);
|
|
248
|
+
const result = await runOrDiagnose(['drive', 'search', 'q'], { stripMedia: true });
|
|
249
|
+
const parsed = JSON.parse(result.content[0].text as string);
|
|
250
|
+
expect(parsed.files[0]).toEqual({ id: 'f1', hasThumbnail: false, webViewLink: 'https://docs/f1' });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('does not strip media unless asked', async () => {
|
|
254
|
+
vi.mocked(runner.run).mockResolvedValue('{"thumbnailLink":"https://x/y=s220"}');
|
|
255
|
+
const result = await runOrDiagnose(['drive', 'get', 'f1'], {});
|
|
256
|
+
expect(result.content[0].text).toBe('{"thumbnailLink":"https://x/y=s220"}');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Non-JSON must survive the strip path exactly as it survives minification —
|
|
260
|
+
// the guard is shared, so a regression here would be silent.
|
|
261
|
+
it('passes non-JSON through untouched even when stripping is on', async () => {
|
|
262
|
+
vi.mocked(runner.run).mockResolvedValue('user@gmail.com');
|
|
263
|
+
const result = await runOrDiagnose(['auth', 'list'], { stripMedia: true });
|
|
264
|
+
expect(result.content[0].text).toBe('user@gmail.com');
|
|
265
|
+
});
|
|
266
|
+
|
|
126
267
|
it('appends auth list on non-auth failure', async () => {
|
|
127
268
|
vi.mocked(runner.run)
|
|
128
269
|
.mockRejectedValueOnce(new Error('Doc not found'))
|