gogcli-mcp 2.28.0 → 2.29.1
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 +6596 -2073
- package/dist/lib.js +6407 -1923
- package/manifest.json +5 -5
- package/mint.yaml +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
- package/src/runner.ts +1 -1
- package/src/tools/calendar.ts +32 -2
- 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/drive.test.ts +81 -13
- package/tests/tools/utils.test.ts +141 -0
- package/tests/zod-single-copy.test.ts +56 -0
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.
|
|
6
|
+
"version": "2.29.1",
|
|
7
7
|
"description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
},
|
|
127
127
|
{
|
|
128
128
|
"name": "gog_calendar_events",
|
|
129
|
-
"description": "List calendar events;
|
|
129
|
+
"description": "List calendar events; compact by default (view=full for descriptions and attendees), paginated and flags a truncated range"
|
|
130
130
|
},
|
|
131
131
|
{
|
|
132
132
|
"name": "gog_calendar_get",
|
|
@@ -254,11 +254,11 @@
|
|
|
254
254
|
},
|
|
255
255
|
{
|
|
256
256
|
"name": "gog_drive_ls",
|
|
257
|
-
"description": "List files in a Google Drive folder"
|
|
257
|
+
"description": "List files in a Google Drive folder; compact by default (view=full for owners, parents and thumbnails)"
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
260
|
"name": "gog_drive_search",
|
|
261
|
-
"description": "Search Google Drive files"
|
|
261
|
+
"description": "Search Google Drive files; compact by default (view=full for thumbnails)"
|
|
262
262
|
},
|
|
263
263
|
{
|
|
264
264
|
"name": "gog_drive_extract_text",
|
|
@@ -270,7 +270,7 @@
|
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
"name": "gog_drive_get",
|
|
273
|
-
"description": "Get Google Drive file metadata"
|
|
273
|
+
"description": "Get Google Drive file metadata; compact by default (view=full for thumbnails)"
|
|
274
274
|
},
|
|
275
275
|
{
|
|
276
276
|
"name": "gog_drive_mkdir",
|
package/mint.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.29.1",
|
|
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>",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
"test:coverage": "vitest run --coverage"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@chrischall/mcp-utils": "^0.
|
|
44
|
+
"@chrischall/mcp-utils": "^0.23.0",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
46
|
-
"zod": "^4.4
|
|
46
|
+
"zod": "^4.5.4"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@types/node": "^26.4.
|
|
49
|
+
"@types/node": "^26.4.1",
|
|
50
50
|
"@vitest/coverage-v8": "^4.1.8",
|
|
51
51
|
"esbuild": "^0.28.2",
|
|
52
52
|
"typescript": "^7.0.2",
|
package/server.json
CHANGED
|
@@ -7,12 +7,12 @@
|
|
|
7
7
|
"source": "github",
|
|
8
8
|
"subfolder": "packages/gogcli-mcp"
|
|
9
9
|
},
|
|
10
|
-
"version": "2.
|
|
10
|
+
"version": "2.29.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"identifier": "gogcli-mcp",
|
|
15
|
-
"version": "2.
|
|
15
|
+
"version": "2.29.1",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|
|
18
18
|
},
|
package/src/runner.ts
CHANGED
|
@@ -217,7 +217,7 @@ const TIMEOUT_MS = 30_000;
|
|
|
217
217
|
// so the requirement change is surfaced in the release notes (see
|
|
218
218
|
// .github/release.yml). This is the single source of truth for the required
|
|
219
219
|
// version; keep the README/CLAUDE.md mention in sync.
|
|
220
|
-
export const MIN_GOG_VERSION = '0.39.
|
|
220
|
+
export const MIN_GOG_VERSION = '0.39.1';
|
|
221
221
|
|
|
222
222
|
// Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
|
|
223
223
|
// values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
|
package/src/tools/calendar.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import { viewParam, resolveView } from '@chrischall/mcp-utils';
|
|
3
4
|
import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
|
|
4
5
|
import { annotateTruncatedList } from '../pagination.js';
|
|
5
6
|
|
|
@@ -53,6 +54,27 @@ function pushReminderFlags(
|
|
|
53
54
|
for (const reminder of p.reminders) args.push(`--reminder=${reminder}`);
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
|
|
58
|
+
// The `compact` rung for gog_calendar_events, as a Google Calendar field mask.
|
|
59
|
+
//
|
|
60
|
+
// nextPageToken FIRST and always. This tool's own description tells a caller
|
|
61
|
+
// that a wide range is usually incomplete and to page until the cursor is gone;
|
|
62
|
+
// a mask of `items(...)` alone drops that cursor from the envelope, which would
|
|
63
|
+
// turn that instruction into a guarantee of the wrong answer. Verified live
|
|
64
|
+
// against gog 0.39.0.
|
|
65
|
+
//
|
|
66
|
+
// Chosen from the DATA over a 25-event window: description costs 5,951 bytes
|
|
67
|
+
// and attendees 3,145 — the two fat blobs `full` exists to return — while etag,
|
|
68
|
+
// kind, iCalUID, eventType, timezone, guestsCanInviteOthers and privateCopy are
|
|
69
|
+
// internal or single-valued across every row. Net: 21,260 -> 7,292 bytes, 66%
|
|
70
|
+
// smaller. status is kept despite being single-valued in that sample precisely
|
|
71
|
+
// because its whole value is flagging the rare cancelled event.
|
|
72
|
+
//
|
|
73
|
+
// gog's derived fields (startLocal, endDayOfWeek, ...) survive the mask, since
|
|
74
|
+
// gog computes them from start/end, which the mask keeps.
|
|
75
|
+
export const CALENDAR_EVENTS_COMPACT_FIELDS =
|
|
76
|
+
'nextPageToken,items(id,summary,start,end,location,status,htmlLink)';
|
|
77
|
+
|
|
56
78
|
export function registerCalendarTools(server: McpServer): void {
|
|
57
79
|
server.registerTool('gog_calendar_events', {
|
|
58
80
|
description: 'List calendar events. Describe the window ONE way and one way only (gog >= 0.36.0 rejects the rest as ambiguous rather than silently discarding a flag): '
|
|
@@ -78,9 +100,13 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
78
100
|
all: z.boolean().optional().describe('Fetch events from ALL CALENDARS. NOTE: unlike the gmail search tools, this does NOT mean "all pages" — it widens the calendar set, not the page window. Use pageToken to reach later pages.'),
|
|
79
101
|
eventTypes: z.array(z.enum(['default', 'birthday', 'focus-time', 'from-gmail', 'out-of-office', 'working-location'])).optional().describe('Filter to specific event types (repeatable)'),
|
|
80
102
|
timezone: z.string().optional().describe('Display timezone for event times (IANA name, e.g. America/New_York, or "local" for the system timezone). Default: each event\'s timezone, then its calendar\'s timezone.'),
|
|
103
|
+
view: viewParam(['compact', 'full'], {
|
|
104
|
+
note: 'compact (the default) drops description and attendees — together two thirds of a '
|
|
105
|
+
+ 'listing\'s bytes — plus etag/iCalUID/kind. Ask for full when you need a body or a guest list.',
|
|
106
|
+
}),
|
|
81
107
|
account: accountParam,
|
|
82
108
|
},
|
|
83
|
-
}, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
|
|
109
|
+
}, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, view, account }) => {
|
|
84
110
|
const args = ['calendar', 'events'];
|
|
85
111
|
if (calendarId) args.push(calendarId);
|
|
86
112
|
if (from) args.push(`--from=${from}`);
|
|
@@ -94,7 +120,11 @@ export function registerCalendarTools(server: McpServer): void {
|
|
|
94
120
|
if (all) args.push('--all');
|
|
95
121
|
if (eventTypes) for (const t of eventTypes) args.push(`--event-types=${t}`);
|
|
96
122
|
if (timezone) args.push(`--timezone=${timezone}`);
|
|
97
|
-
const
|
|
123
|
+
const rung = resolveView(view, ['compact', 'full']);
|
|
124
|
+
const result = await runOrDiagnose(args, {
|
|
125
|
+
account,
|
|
126
|
+
fieldsMask: rung === 'compact' ? CALENDAR_EVENTS_COMPACT_FIELDS : undefined,
|
|
127
|
+
});
|
|
98
128
|
// No count probe here: unlike Gmail's list endpoints, the Calendar API has
|
|
99
129
|
// no cheap way to count a range exactly, so the warning carries the fact of
|
|
100
130
|
// truncation without inventing a total.
|
package/src/tools/drive.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
4
|
+
import { rawTextResult, viewParam, resolveView } from '@chrischall/mcp-utils';
|
|
5
|
+
|
|
5
6
|
import { run, runBinary } from '../runner.js';
|
|
6
7
|
import { accountParam, diagnose, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken} from './utils.js';
|
|
7
8
|
|
|
@@ -18,6 +19,21 @@ function fileMeta(raw: string): { name?: string; mimeType?: string } {
|
|
|
18
19
|
return { name: f.name, mimeType: f.mimeType };
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
|
|
23
|
+
// The `compact` rung for gog_drive_ls, as a Google Drive field mask.
|
|
24
|
+
//
|
|
25
|
+
// nextPageToken FIRST and always: a mask of `files(...)` alone drops the cursor
|
|
26
|
+
// from the envelope, and a compact listing would then look complete when it was
|
|
27
|
+
// one page of many. Verified live against gog 0.39.0.
|
|
28
|
+
//
|
|
29
|
+
// The dropped fields were chosen from the DATA, not from taste — measured over
|
|
30
|
+
// a 25-row listing: thumbnailLink costs 4,998 bytes at ONE distinct value,
|
|
31
|
+
// owners 1,400 at one, parents 900 at one, hasThumbnail 550 at one. Everything
|
|
32
|
+
// kept below varies across rows. Net: 15,718 -> 8,095 bytes, 48% smaller.
|
|
33
|
+
// A caller who needs an owner or a parent asks for view="full".
|
|
34
|
+
export const DRIVE_LS_COMPACT_FIELDS =
|
|
35
|
+
'nextPageToken,files(id,name,mimeType,modifiedTime,size,webViewLink)';
|
|
36
|
+
|
|
21
37
|
export function registerDriveTools(server: McpServer): void {
|
|
22
38
|
server.registerTool('gog_drive_ls', {
|
|
23
39
|
description: 'List files in a Google Drive folder (default: root).',
|
|
@@ -29,9 +45,13 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
29
45
|
page: pageAliasParam,
|
|
30
46
|
query: z.string().optional().describe('Drive query filter (e.g. "name contains \'budget\'")'),
|
|
31
47
|
allDrives: z.boolean().optional().describe('Include shared drives (default: true). Set false for My Drive only.'),
|
|
48
|
+
view: viewParam(['compact', 'full'], {
|
|
49
|
+
note: 'compact (the default) drops owners, parents, thumbnailLink and hasThumbnail — '
|
|
50
|
+
+ 'near-constant across a listing and 48% of its bytes. Ask for full to get them.',
|
|
51
|
+
}),
|
|
32
52
|
account: accountParam,
|
|
33
53
|
},
|
|
34
|
-
}, async ({ folderId, max, pageToken, page, query, allDrives, account }) => {
|
|
54
|
+
}, async ({ folderId, max, pageToken, page, query, allDrives, view, account }) => {
|
|
35
55
|
const args = ['drive', 'ls'];
|
|
36
56
|
if (folderId) args.push(`--parent=${folderId}`);
|
|
37
57
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
@@ -39,7 +59,11 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
39
59
|
if (token) args.push(`--page=${token}`);
|
|
40
60
|
if (query) args.push(`--query=${query}`);
|
|
41
61
|
if (allDrives === false) args.push('--no-all-drives');
|
|
42
|
-
|
|
62
|
+
const rung = resolveView(view, ['compact', 'full']);
|
|
63
|
+
return runOrDiagnose(args, {
|
|
64
|
+
account,
|
|
65
|
+
fieldsMask: rung === 'compact' ? DRIVE_LS_COMPACT_FIELDS : undefined,
|
|
66
|
+
});
|
|
43
67
|
});
|
|
44
68
|
|
|
45
69
|
server.registerTool('gog_drive_search', {
|
|
@@ -47,10 +71,17 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
47
71
|
annotations: { readOnlyHint: true },
|
|
48
72
|
inputSchema: {
|
|
49
73
|
query: z.string().describe('Search query'),
|
|
74
|
+
// gog's `drive search` accepts no --fields mask, so unlike gog_drive_ls
|
|
75
|
+
// this tool's compact rung is a LOCAL projection. Same vocabulary either
|
|
76
|
+
// way: a caller does not need to know which lever is being pulled.
|
|
77
|
+
view: viewParam(['compact', 'full'], { note: 'compact (the default) drops thumbnailLink — a URL a model cannot see, and 30%+ of a Drive file record. Ask for full to get it back.' }),
|
|
50
78
|
account: accountParam,
|
|
51
79
|
},
|
|
52
|
-
}, async ({ query, account }) => {
|
|
53
|
-
return runOrDiagnose(['drive', 'search', query], {
|
|
80
|
+
}, async ({ query, view, account }) => {
|
|
81
|
+
return runOrDiagnose(['drive', 'search', query], {
|
|
82
|
+
account,
|
|
83
|
+
stripMedia: resolveView(view, ['compact', 'full']) === 'compact',
|
|
84
|
+
});
|
|
54
85
|
});
|
|
55
86
|
|
|
56
87
|
server.registerTool('gog_drive_get', {
|
|
@@ -58,10 +89,20 @@ export function registerDriveTools(server: McpServer): void {
|
|
|
58
89
|
annotations: { readOnlyHint: true },
|
|
59
90
|
inputSchema: {
|
|
60
91
|
fileId: z.string().describe('File ID'),
|
|
92
|
+
// A --fields mask saves only 7% here: the default set is already narrow,
|
|
93
|
+
// which is why this tool takes no mask. The media strip saves 27.5% of
|
|
94
|
+
// the tool's actual output, measured end to end over stdio, which is why
|
|
95
|
+
// it takes a view after all. (An earlier note said 32.9%; that was a
|
|
96
|
+
// minified-vs-stripped comparison of the raw gog payload rather than of
|
|
97
|
+
// what the tool returns. The end-to-end figure is the one a caller sees.)
|
|
98
|
+
view: viewParam(['compact', 'full'], { note: 'compact (the default) drops thumbnailLink — a URL a model cannot see, and 30%+ of a Drive file record. Ask for full to get it back.' }),
|
|
61
99
|
account: accountParam,
|
|
62
100
|
},
|
|
63
|
-
}, async ({ fileId, account }) => {
|
|
64
|
-
return runOrDiagnose(['drive', 'get', fileId], {
|
|
101
|
+
}, async ({ fileId, view, account }) => {
|
|
102
|
+
return runOrDiagnose(['drive', 'get', fileId], {
|
|
103
|
+
account,
|
|
104
|
+
stripMedia: resolveView(view, ['compact', 'full']) === 'compact',
|
|
105
|
+
});
|
|
65
106
|
});
|
|
66
107
|
|
|
67
108
|
server.registerTool('gog_drive_mkdir', {
|
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.1'; // 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 () => {
|