gogcli-mcp 2.30.0 → 4.0.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/index.js +15345 -18417
  4. package/dist/lib.js +13720 -9481
  5. package/manifest.json +2 -2
  6. package/mint.yaml +37 -33
  7. package/package.json +5 -5
  8. package/server.json +2 -2
  9. package/src/attachments.ts +28 -34
  10. package/src/blob-upload.ts +165 -134
  11. package/src/blob-urls.ts +3 -5
  12. package/src/bootstrap-auth.ts +97 -0
  13. package/src/gmail-dispatch-guard.ts +106 -0
  14. package/src/gmail-results.ts +1 -1
  15. package/src/index.ts +3 -4
  16. package/src/lib.ts +24 -9
  17. package/src/pagination.ts +1 -1
  18. package/src/runner.ts +23 -175
  19. package/src/tools/api.ts +7 -7
  20. package/src/tools/appscript.ts +16 -16
  21. package/src/tools/auth.ts +16 -16
  22. package/src/tools/calendar.ts +13 -13
  23. package/src/tools/chat.ts +25 -25
  24. package/src/tools/classroom.ts +49 -49
  25. package/src/tools/contacts.ts +9 -9
  26. package/src/tools/docs.ts +13 -13
  27. package/src/tools/drive.ts +23 -25
  28. package/src/tools/gmail.ts +145 -32
  29. package/src/tools/sheets.ts +15 -15
  30. package/src/tools/slides.ts +13 -13
  31. package/src/tools/tasks.ts +13 -13
  32. package/src/tools/utils.ts +14 -61
  33. package/tests/attachments.test.ts +11 -14
  34. package/tests/blob-upload.test.ts +235 -160
  35. package/tests/bootstrap-auth.test.ts +245 -0
  36. package/tests/gmail-dispatch-guard.test.ts +132 -0
  37. package/tests/runner-file-args.test.ts +1 -13
  38. package/tests/runner.test.ts +8 -95
  39. package/tests/sdk-single-copy.test.ts +11 -37
  40. package/tests/tools/appscript.test.ts +1 -1
  41. package/tests/tools/auth-401-shapes.test.ts +2 -3
  42. package/tests/tools/auth.test.ts +5 -4
  43. package/tests/tools/chat.test.ts +1 -1
  44. package/tests/tools/drive.test.ts +11 -3
  45. package/tests/tools/gmail.test.ts +244 -14
  46. package/tests/tools/sheets.test.ts +1 -1
  47. package/tests/tools/utils.test.ts +1 -50
  48. package/tests/zod-single-copy.test.ts +8 -16
  49. package/tsconfig.json +1 -4
  50. package/vitest.config.ts +2 -14
  51. package/src/auth-log.ts +0 -205
  52. package/src/connector-auth.ts +0 -303
  53. package/src/connector-runtime.ts +0 -887
  54. package/src/google-probe.ts +0 -113
  55. package/src/google-token.ts +0 -391
  56. package/src/remote-runner.ts +0 -77
  57. package/src/worker.ts +0 -129
  58. package/tests/auth-log.test.ts +0 -530
  59. package/tests/connector-auth.test.ts +0 -559
  60. package/tests/connector-runtime.test.ts +0 -1644
  61. package/tests/google-probe.test.ts +0 -116
  62. package/tests/google-token.test.ts +0 -425
  63. package/tests/remote-runner.test.ts +0 -202
  64. package/tests/worker.test.ts +0 -167
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,13 +6,13 @@ export function registerSlidesTools(server: McpServer): void {
6
6
  server.registerTool('gog_slides_export', {
7
7
  description: 'Export a Google Slides presentation to a local file (pdf or pptx).',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  presentationId: z.string().describe('Presentation ID'),
11
11
  out: z.string().optional().describe('Output file path'),
12
12
  format: z.enum(['pdf', 'pptx']).optional().describe('Export format (default: pptx)'),
13
13
  overwrite: z.boolean().optional().describe('Overwrite the output file if it already exists (gog refuses otherwise)'),
14
14
  account: accountParam,
15
- },
15
+ }),
16
16
  }, async ({ presentationId, out, format, overwrite, account }) => {
17
17
  const args = ['slides', 'export', presentationId];
18
18
  if (out) args.push(`--out=${out}`);
@@ -24,22 +24,22 @@ export function registerSlidesTools(server: McpServer): void {
24
24
  server.registerTool('gog_slides_info', {
25
25
  description: 'Get metadata for a Google Slides presentation (title, ID, slide count, etc.).',
26
26
  annotations: { readOnlyHint: true },
27
- inputSchema: {
27
+ inputSchema: z.object({
28
28
  presentationId: z.string().describe('Presentation ID'),
29
29
  account: accountParam,
30
- },
30
+ }),
31
31
  }, async ({ presentationId, account }) => {
32
32
  return runOrDiagnose(['slides', 'info', presentationId], { account });
33
33
  });
34
34
 
35
35
  server.registerTool('gog_slides_create', {
36
36
  description: 'Create a new Google Slides presentation, optionally in a folder or copying from a template.',
37
- inputSchema: {
37
+ inputSchema: z.object({
38
38
  title: z.string().describe('Presentation title'),
39
39
  parent: z.string().optional().describe('Destination folder ID'),
40
40
  template: z.string().optional().describe('Template presentation ID to copy from'),
41
41
  account: accountParam,
42
- },
42
+ }),
43
43
  }, async ({ title, parent, template, account }) => {
44
44
  const args = ['slides', 'create', title];
45
45
  if (parent) args.push(`--parent=${parent}`);
@@ -49,12 +49,12 @@ export function registerSlidesTools(server: McpServer): void {
49
49
 
50
50
  server.registerTool('gog_slides_copy', {
51
51
  description: 'Copy a Google Slides presentation to a new presentation with the given title.',
52
- inputSchema: {
52
+ inputSchema: z.object({
53
53
  presentationId: z.string().describe('Presentation ID to copy'),
54
54
  title: z.string().describe('Title for the new copy'),
55
55
  parent: z.string().optional().describe('Destination folder ID'),
56
56
  account: accountParam,
57
- },
57
+ }),
58
58
  }, async ({ presentationId, title, parent, account }) => {
59
59
  const args = ['slides', 'copy', presentationId, title];
60
60
  if (parent) args.push(`--parent=${parent}`);
@@ -64,10 +64,10 @@ export function registerSlidesTools(server: McpServer): void {
64
64
  server.registerTool('gog_slides_list_slides', {
65
65
  description: 'List slides in a Google Slides presentation.',
66
66
  annotations: { readOnlyHint: true },
67
- inputSchema: {
67
+ inputSchema: z.object({
68
68
  presentationId: z.string().describe('Presentation ID'),
69
69
  account: accountParam,
70
- },
70
+ }),
71
71
  }, async ({ presentationId, account }) => {
72
72
  return runOrDiagnose(['slides', 'list-slides', presentationId], { account });
73
73
  });
@@ -75,12 +75,12 @@ export function registerSlidesTools(server: McpServer): void {
75
75
  server.registerTool('gog_slides_read_slide', {
76
76
  description: 'Read the content of a slide (text, shapes, speaker notes). Set detail=true to also include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs.',
77
77
  annotations: { readOnlyHint: true },
78
- inputSchema: {
78
+ inputSchema: z.object({
79
79
  presentationId: z.string().describe('Presentation ID'),
80
80
  slideId: z.string().describe('Slide ID to read'),
81
81
  detail: z.boolean().optional().describe('Include normalized element geometry, styled text runs, paragraphs, table-cell content, and image source URLs'),
82
82
  account: accountParam,
83
- },
83
+ }),
84
84
  }, async ({ presentationId, slideId, detail, account }) => {
85
85
  const args = ['slides', 'read-slide', presentationId, slideId];
86
86
  if (detail) args.push('--detail');
@@ -1,4 +1,4 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
4
4
 
@@ -6,9 +6,9 @@ export function registerTasksTools(server: McpServer): void {
6
6
  server.registerTool('gog_tasks_lists', {
7
7
  description: 'List all Google Task lists.',
8
8
  annotations: { readOnlyHint: true },
9
- inputSchema: {
9
+ inputSchema: z.object({
10
10
  account: accountParam,
11
- },
11
+ }),
12
12
  }, async ({ account }) => {
13
13
  return runOrDiagnose(['tasks', 'lists', 'list'], { account });
14
14
  });
@@ -16,10 +16,10 @@ export function registerTasksTools(server: McpServer): void {
16
16
  server.registerTool('gog_tasks_list', {
17
17
  description: 'List tasks in a Google Task list.',
18
18
  annotations: { readOnlyHint: true },
19
- inputSchema: {
19
+ inputSchema: z.object({
20
20
  tasklistId: z.string().describe('Task list ID (use gog_tasks_lists to find IDs)'),
21
21
  account: accountParam,
22
- },
22
+ }),
23
23
  }, async ({ tasklistId, account }) => {
24
24
  return runOrDiagnose(['tasks', 'list', tasklistId], { account });
25
25
  });
@@ -27,11 +27,11 @@ export function registerTasksTools(server: McpServer): void {
27
27
  server.registerTool('gog_tasks_get', {
28
28
  description: 'Get a specific task by ID.',
29
29
  annotations: { readOnlyHint: true },
30
- inputSchema: {
30
+ inputSchema: z.object({
31
31
  tasklistId: z.string().describe('Task list ID'),
32
32
  taskId: z.string().describe('Task ID'),
33
33
  account: accountParam,
34
- },
34
+ }),
35
35
  }, async ({ tasklistId, taskId, account }) => {
36
36
  return runOrDiagnose(['tasks', 'get', tasklistId, taskId], { account });
37
37
  });
@@ -39,13 +39,13 @@ export function registerTasksTools(server: McpServer): void {
39
39
  server.registerTool('gog_tasks_add', {
40
40
  description: 'Add a task to a Google Task list.',
41
41
  annotations: { destructiveHint: false },
42
- inputSchema: {
42
+ inputSchema: z.object({
43
43
  tasklistId: z.string().describe('Task list ID'),
44
44
  title: z.string().describe('Task title'),
45
45
  notes: z.string().optional().describe('Task notes/description'),
46
46
  due: z.string().optional().describe('Due date (YYYY-MM-DD or RFC3339)'),
47
47
  account: accountParam,
48
- },
48
+ }),
49
49
  }, async ({ tasklistId, title, notes, due, account }) => {
50
50
  const args = ['tasks', 'add', tasklistId, `--title=${title}`];
51
51
  if (notes) args.push(`--notes=${notes}`);
@@ -56,11 +56,11 @@ export function registerTasksTools(server: McpServer): void {
56
56
  server.registerTool('gog_tasks_done', {
57
57
  description: 'Mark a task as completed.',
58
58
  annotations: { destructiveHint: true },
59
- inputSchema: {
59
+ inputSchema: z.object({
60
60
  tasklistId: z.string().describe('Task list ID'),
61
61
  taskId: z.string().describe('Task ID'),
62
62
  account: accountParam,
63
- },
63
+ }),
64
64
  }, async ({ tasklistId, taskId, account }) => {
65
65
  return runOrDiagnose(['tasks', 'done', tasklistId, taskId], { account });
66
66
  });
@@ -68,11 +68,11 @@ export function registerTasksTools(server: McpServer): void {
68
68
  server.registerTool('gog_tasks_delete', {
69
69
  description: 'Delete a task.',
70
70
  annotations: { destructiveHint: true },
71
- inputSchema: {
71
+ inputSchema: z.object({
72
72
  tasklistId: z.string().describe('Task list ID'),
73
73
  taskId: z.string().describe('Task ID'),
74
74
  account: accountParam,
75
- },
75
+ }),
76
76
  }, async ({ tasklistId, taskId, account }) => {
77
77
  // gog gates this delete behind a confirmation; the runner injects
78
78
  // --no-input, so without --force it refuses at runtime.
@@ -1,9 +1,8 @@
1
1
  import { z } from 'zod';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { McpServer, CallToolResult } from '@modelcontextprotocol/server';
4
3
  import { errorResult, rawTextResult, minifiedResult, stripMediaUrls } from '@chrischall/mcp-utils';
5
- import { run, isRunnerTransportError } from '../runner.js';
6
- import type { GogArg, RunnerFailureKind } from '../runner.js';
4
+ import { run } from '../runner.js';
5
+ import type { GogArg } from '../runner.js';
7
6
  import { normalizeTimestamps } from '../timestamps.js';
8
7
  import { stripConsumedPageToken } from '../pagination.js';
9
8
 
@@ -16,23 +15,20 @@ import { stripConsumedPageToken } from '../pagination.js';
16
15
  // "\n" cannot round-trip byte-for-byte through the file path. Second, the file
17
16
  // path costs a temp dir, a write, and a delete per call.
18
17
  //
19
- // The value matches the per-arg byte limit the Fly runner enforced BEFORE large
20
- // payloads could leave argv (the old MAX_ARG_LEN, 4096). That is deliberate:
21
- // every body that used to round-trip inline byte-for-byte still does, so this
22
- // change adds no trailing-newline regression for any body that already worked —
23
- // only bodies that previously exceeded the cap and hard-failed ("each arg must
24
- // be at most 4096 chars") now take the file path and its newline trim. The
25
- // runner's plain-arg cap is now 64 KiB, so a 4096-byte inline value is nowhere
26
- // near being rejected.
18
+ // The value is the historical per-arg cap (4096) that applied BEFORE large
19
+ // payloads could leave argv. That is deliberate: every body that used to
20
+ // round-trip inline byte-for-byte still does, so there is no trailing-newline
21
+ // regression for any body that already worked — only bodies that previously
22
+ // exceeded the cap and hard-failed now take the file path and its newline trim.
27
23
  export const PAYLOAD_INLINE_MAX = 4096;
28
24
 
29
25
  // The ONE place the inline-vs-file decision is made. Every tool that has a
30
26
  // gog `--x` / `--x-file` flag pair routes its value through here so the
31
27
  // threshold cannot drift between tools.
32
28
  //
33
- // Measures BYTES, not characters: the Fly runner's cap and the Linux kernel's
34
- // MAX_ARG_STRLEN are both byte-based, so a multibyte-heavy body (CJK, emoji)
35
- // would slip past a `.length` check at up to 4x its real argv cost.
29
+ // Measures BYTES, not characters: the Linux kernel's MAX_ARG_STRLEN is
30
+ // byte-based, so a multibyte-heavy body (CJK, emoji) would slip past a
31
+ // `.length` check at up to 4x its real argv cost.
36
32
  export function payloadArg(
37
33
  inlineFlag: string,
38
34
  fileFlag: string,
@@ -149,7 +145,7 @@ export function registerRunTool(
149
145
  server.registerTool(`gog_${service}_run`, {
150
146
  description,
151
147
  annotations: { destructiveHint: true },
152
- inputSchema,
148
+ inputSchema: z.object(inputSchema),
153
149
  }, async (rawArgs) => {
154
150
  const { subcommand, args, account } = rawArgs as { subcommand: string; args: string[]; account?: string };
155
151
  return runOrDiagnose([service, subcommand, ...args], { account });
@@ -237,31 +233,6 @@ const GRID_LIMIT_HINT =
237
233
  '\n\nThe target range is outside the sheet\'s current grid. Add the missing rows or columns ' +
238
234
  'first with gog_sheets_insert (dimension: rows or cols), then retry the write.';
239
235
 
240
- // The hint for each RUNNER-authored failure kind (see RunnerTransportError in
241
- // runner.ts). These are chosen by the error's TYPE, never by reading its text.
242
- //
243
- // transport-auth is the one that motivated all of this. The runner answers a
244
- // bad bearer with the single word "unauthorized"; read as prose that is
245
- // indistinguishable from Google rejecting a credential, and the caller was
246
- // being told all session to re-authorize an account that had never been asked
247
- // for anything. So this hint names the real cause and says outright that
248
- // re-authorizing cannot help. It deliberately does NOT contain the literal
249
- // `gog_auth_add`, which is the token the rest of the auth guidance keys on.
250
- const RUNNER_TRANSPORT_AUTH_HINT =
251
- '\n\nThis is the CONNECTOR\'s own transport auth failing, not your Google sign-in. The gog-runner ' +
252
- 'backend rejected the bearer token this server sent, so the request never reached gog and no Google ' +
253
- 'credential was checked — the Google account is not the problem and re-authorizing it cannot fix this. ' +
254
- 'An operator must make the Worker secret GOG_RUNNER_KEY equal RUNNER_KEY on the Fly app ' +
255
- '(wrangler secret put GOG_RUNNER_KEY / fly secrets set RUNNER_KEY), then retry.';
256
-
257
- const RUNNER_TRANSPORT_HINTS: Record<RunnerFailureKind, string> = {
258
- 'transport-auth': RUNNER_TRANSPORT_AUTH_HINT,
259
- // The request itself was malformed, so the runner will refuse it identically
260
- // every time. Nothing to advise beyond the message the runner already gave.
261
- 'transport-request': '',
262
- 'transport-retryable': TRANSIENT_HINT,
263
- };
264
-
265
236
  // Reduce `gog auth list --json` output to just the configured email addresses.
266
237
  // The raw JSON also carries OAuth scopes, the Google subject id, and creation
267
238
  // timestamps — none of which belong in an error surfaced to the model, and
@@ -292,21 +263,6 @@ export function formatAccountList(raw: string): string {
292
263
  export async function diagnose(err: unknown): Promise<CallToolResult> {
293
264
  const errText = errorText(err);
294
265
 
295
- // STRUCTURE BEFORE PROSE. A RunnerTransportError is this connector's own
296
- // transport failing — its bearer, its request validation, its drain. Nothing
297
- // was shown to Google, so none of the patterns below may be consulted for it:
298
- // they exist to read gog's/Google's words, and the runner's words are not
299
- // those. Read as prose, the runner's `unauthorized` matched
300
- // DEFINITE_AUTH_PATTERN and produced AUTH_HINT — a human being told to
301
- // re-authorize a healthy account over what was really a key mismatch.
302
- //
303
- // This short-circuits the ladder rather than joining it, so it is not a new
304
- // rung in the precedence order documented below; that order still governs
305
- // every error that genuinely came from gog.
306
- const transportHint = isRunnerTransportError(err)
307
- ? RUNNER_TRANSPORT_HINTS[err.kind]
308
- : undefined;
309
-
310
266
  const isInvalidGrant = INVALID_GRANT_PATTERN.test(errText);
311
267
 
312
268
  // Precedence, and the reason for it. Reporting needs-auth is EXPENSIVE to be
@@ -322,10 +278,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
322
278
  const isTransientError = !DEFINITE_AUTH_PATTERN.test(errText) && TRANSIENT_ERROR_PATTERN.test(errText);
323
279
  const isAuthError = !isTransientError && AUTH_ERROR_PATTERN.test(errText);
324
280
  const isGridLimitError = GRID_LIMIT_ERROR_PATTERN.test(errText);
325
- // `??`, not `||`: 'transport-request' maps to the empty string on purpose —
326
- // "this failure is ours and there is nothing to advise" — and `||` would fall
327
- // through to the prose ladder for exactly the errors that must never reach it.
328
- const hint = transportHint ?? (isInvalidGrant
281
+ const hint = isInvalidGrant
329
282
  ? INVALID_GRANT_HINT
330
283
  : isAuthError
331
284
  ? AUTH_HINT
@@ -333,7 +286,7 @@ export async function diagnose(err: unknown): Promise<CallToolResult> {
333
286
  ? TRANSIENT_HINT
334
287
  : isGridLimitError
335
288
  ? GRID_LIMIT_HINT
336
- : '');
289
+ : '';
337
290
  try {
338
291
  const accounts = formatAccountList(await run(['auth', 'list']));
339
292
  return errorResult(`${errText}\n\nConfigured accounts:\n${accounts || '(none)'}${hint}`);
@@ -125,9 +125,8 @@ describe('inlineAttachmentArgs', () => {
125
125
  // The budget belongs to the REQUEST, not to the attachments. `payloadArg`
126
126
  // turns any body over 4 KiB into a GogFileArg that rides in the same JSON
127
127
  // body at ~1:1, so a near-max attachment set plus a multi-MiB body overruns
128
- // the runner even though each input is inside its own documented limit. That
129
- // is the same invisible-transport-rejection failure the ceiling exists to
130
- // prevent, so the sibling args are measured rather than assumed small.
128
+ // the budget even though each input is inside its own documented limit, so
129
+ // the sibling args are measured rather than assumed small.
131
130
  it('counts the message body against the same budget as the attachments', () => {
132
131
  // Three files just under the 8 MiB per-file cap, summing to just under the
133
132
  // per-message total — i.e. every input inside its own documented limit.
@@ -176,20 +175,18 @@ describe('inlineAttachmentArgs', () => {
176
175
 
177
176
  // THE INVARIANT behind the per-message ceiling, asserted rather than trusted.
178
177
  //
179
- // connector-runtime sends every payload base64-encoded inside ONE JSON body,
180
- // and the Fly runner caps that body at MAX_BODY_BYTES. Base64 inflates by 4/3,
181
- // so a ceiling expressed in decoded bytes has to be derived from the wire cap
182
- // or it documents a size that gets rejected as "request body too large" — a
183
- // transport rejection from a layer the caller cannot see, which is the exact
184
- // failure the tool-layer check exists to prevent. A 25 MiB total encoded to
185
- // 34,952,536 chars against a 33,554,432 cap, so the limit was unreachable.
186
- it('keeps a full message under the Fly runner request-body cap once base64-inflated', () => {
187
- const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024; // fly-gog-runner/server.mjs
178
+ // Every payload rides base64-encoded inside ONE JSON body, capped at a 32 MiB
179
+ // request budget. Base64 inflates by 4/3, so a ceiling expressed in decoded
180
+ // bytes has to be derived from the wire cap or it documents a size that cannot
181
+ // actually be sent. A 25 MiB total encoded to 34,952,536 chars against a
182
+ // 33,554,432 cap, so the limit was unreachable.
183
+ it('keeps a full message under the 32 MiB request budget once base64-inflated', () => {
184
+ const REQUEST_MAX_BODY_BYTES = 32 * 1024 * 1024;
188
185
  const encodedLength = (decoded: number): number => 4 * Math.ceil(decoded / 3);
189
186
 
190
187
  // The payload budget must leave the JSON structure room inside the cap…
191
- expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeLessThan(RUNNER_MAX_BODY_BYTES);
192
- expect(RUNNER_MAX_BODY_BYTES - MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeGreaterThanOrEqual(128 * 1024);
188
+ expect(MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeLessThan(REQUEST_MAX_BODY_BYTES);
189
+ expect(REQUEST_MAX_BODY_BYTES - MAX_REQUEST_PAYLOAD_WIRE_BYTES).toBeGreaterThanOrEqual(128 * 1024);
193
190
  // …and a full attachment set must fit inside that budget once inflated.
194
191
  expect(encodedLength(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)).toBeLessThanOrEqual(MAX_REQUEST_PAYLOAD_WIRE_BYTES);
195
192
  // The advertised number must itself be sendable — floor, not round.