gogcli-mcp 2.24.0 → 2.26.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/src/runner.ts CHANGED
@@ -18,12 +18,43 @@ export type Spawner = (
18
18
  export interface GogFileArg {
19
19
  /** Discriminant separating this from a plain argv string. */
20
20
  kind: 'file';
21
- /** Flag NAME without leading dashes, e.g. 'body-html-file'. */
21
+ /**
22
+ * Flag NAME without leading dashes, e.g. 'body-html-file'. The materialized
23
+ * path is passed as `--<flag>=<path>`, EXCEPT when `positional` is set, where
24
+ * this names the temp file's parent directory and nothing else.
25
+ */
22
26
  flag: string;
23
- /** The large payload, verbatim. */
27
+ /**
28
+ * The payload. Text verbatim when `encoding` is 'utf8' (the default); the
29
+ * base64 spelling of the bytes when it is 'base64'.
30
+ */
24
31
  contents: string;
25
32
  /** Temp-file extension without the dot, e.g. 'html'. Defaults to 'txt'. */
26
33
  ext?: string;
34
+ /**
35
+ * How to interpret `contents` when writing it. 'utf8' (default) preserves the
36
+ * existing text-payload behaviour exactly; 'base64' decodes first, which is
37
+ * what lets a caller hand over a PNG or a PDF without a shared filesystem.
38
+ */
39
+ encoding?: 'utf8' | 'base64';
40
+ /**
41
+ * Exact basename for the temp file, overriding the `<flag>.<ext>` default.
42
+ *
43
+ * Load-bearing for attachments: gog reads the MIME part's filename off the
44
+ * path it is given, so a file materialized as `attach.txt` would arrive in the
45
+ * recipient's mailbox named `attach.txt` no matter what the caller called it.
46
+ * Callers MUST pass an already-sanitized single path segment.
47
+ */
48
+ filename?: string;
49
+ /**
50
+ * Emit the materialized path as a BARE argv element instead of `--flag=path`.
51
+ *
52
+ * For subcommands taking the file as a positional argument — `gog drive
53
+ * upload <localPath>` is the only one today. Argument ORDER is preserved by
54
+ * every executor, so a positional file arg lands exactly where it sat in the
55
+ * caller's array.
56
+ */
57
+ positional?: boolean;
27
58
  }
28
59
 
29
60
  export type GogArg = string | GogFileArg;
@@ -157,6 +188,25 @@ export interface RunOptions {
157
188
  // carries no token, so stripping only real token shapes keeps it intact while
158
189
  // still catching any token that unexpectedly appears.
159
190
  redactMode?: 'full' | 'tokens';
191
+ // JSON string fields whose values are OPAQUE binary payloads this wrapper
192
+ // asked for by name — `contentBase64` from `gog gmail attachment --inline`
193
+ // being the only one today. Their values are lifted out before redaction runs
194
+ // and put back verbatim afterwards.
195
+ //
196
+ // Redaction exists to catch a credential that leaked into PROSE. A base64
197
+ // blob is not prose: it is uniformly-distributed bytes over a 64-character
198
+ // alphabet, so given enough of them it will eventually contain the literal
199
+ // spelling of any short secret shape by chance alone — `1//` at ~30% per
200
+ // attachment (see TOKEN_LEFT_BOUNDARY), and `AIza…` at ~0.2% even after that
201
+ // anchor lands. Boundary-anchoring the patterns fixes the common case;
202
+ // exempting the field fixes the CLASS, and keeps a future pattern added to
203
+ // mcp-utils from silently re-breaking attachments.
204
+ //
205
+ // Deliberately narrow in three ways: it is opt-in per call, only the named
206
+ // key is exempt, and only a value that is ENTIRELY base64 alphabet qualifies
207
+ // (see OPAQUE_FIELD_VALUE) — so a field carrying real prose, which is where a
208
+ // real leaked token would live, still gets redacted normally.
209
+ opaqueFields?: readonly string[];
160
210
  }
161
211
 
162
212
  const TIMEOUT_MS = 30_000;
@@ -167,7 +217,7 @@ const TIMEOUT_MS = 30_000;
167
217
  // so the requirement change is surfaced in the release notes (see
168
218
  // .github/release.yml). This is the single source of truth for the required
169
219
  // version; keep the README/CLAUDE.md mention in sync.
170
- export const MIN_GOG_VERSION = '0.37.0';
220
+ export const MIN_GOG_VERSION = '0.38.1';
171
221
 
172
222
  // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
173
223
  // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
@@ -197,6 +247,43 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
197
247
  return result;
198
248
  }
199
249
 
250
+ // The LEFT boundary every Google token shape below is anchored on, and the
251
+ // reason this file has a regression test named after a PNG.
252
+ //
253
+ // `1//` is three characters drawn entirely from the standard base64 alphabet,
254
+ // so the unanchored pattern `1\/\/[A-Za-z0-9._-]+` matches inside ANY base64
255
+ // blob that happens to contain that run — and then eats forward to the next
256
+ // `+` or `/`, deleting a slab out of the middle of the payload. `gog gmail
257
+ // attachment --inline` returns the attachment bytes as base64 in its JSON, that
258
+ // JSON goes through `run()`, and `run()` redacts. The result was a mangled
259
+ // `contentBase64` and an MCP protocol error at the client ("Invalid Base64
260
+ // string") on roughly a THIRD of all attachments — measured, not estimated: a
261
+ // 72 KiB file is ~97k base64 chars and the expected number of `1//` runs is
262
+ // n/64³ ≈ 0.37, i.e. P(corrupt) ≈ 30%.
263
+ //
264
+ // That coin-flip is what made the bug look like it was about FILENAMES: it
265
+ // correlates with nothing a reader can see, so two attachments in one thread
266
+ // differing only in name would land on opposite sides of it. It is content, not
267
+ // name — the runner has always spawned with an argv array and never a shell, so
268
+ // spaces in a filename were never able to split anything.
269
+ //
270
+ // A real token never appears WELDED to base64 text: it is delimited by a quote,
271
+ // whitespace, `=`, `:`, `&`, a bracket, or the start of the string. So requiring
272
+ // a non-base64 character (or nothing) to its left keeps every genuine detection
273
+ // and drops the mid-blob false positives, which by construction are always
274
+ // preceded by another base64 character.
275
+ //
276
+ // The class is EXACTLY the standard base64 alphabet, and no wider. Every
277
+ // character omitted from it is a delimiter a real token is found after, so each
278
+ // one added would silently cost a detection: `=` in particular would stop
279
+ // `refresh_token=1//0e…` and `access_token=ya29.…` — the form-encoded spelling,
280
+ // which the shared redactor's query-param rule does not catch without a
281
+ // preceding `?`/`&` — from being redacted at all. `=` is also unnecessary here,
282
+ // since base64 padding is terminal and can never precede a mid-blob `1//`.
283
+ // Likewise `.`, `_` and `-`: none occurs in standard base64, and `1//` cannot
284
+ // occur in base64url (which has no `/`), so neither alphabet needs them.
285
+ const TOKEN_LEFT_BOUNDARY = '(?<![A-Za-z0-9+/])';
286
+
200
287
  // Redact bearer/refresh-token patterns from error text before surfacing
201
288
  // it back to the MCP client. If gog ever emits a token in stderr (e.g.
202
289
  // from a verbose log mode), this prevents it from leaking to the model.
@@ -204,8 +291,8 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
204
291
  // cookies, well-known key shapes (incl. Google AIza… API keys), and secret
205
292
  // query params — but not Google's OAuth2 token shapes, so those stay here.
206
293
  const GOOGLE_TOKEN_PATTERNS: RegExp[] = [
207
- /ya29\.[A-Za-z0-9._\-]+/g, // OAuth2 access tokens
208
- /1\/\/[A-Za-z0-9._\-]+/g, // OAuth2 refresh tokens
294
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}ya29\\.[A-Za-z0-9._\\-]+`, 'g'), // OAuth2 access tokens
295
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}1//[A-Za-z0-9._\\-]+`, 'g'), // OAuth2 refresh tokens
209
296
  ];
210
297
  // Strip only Google's OAuth2 token shapes. Precise enough to leave an OAuth
211
298
  // consent URL (client_id, scope names, state, code_challenge) untouched.
@@ -220,6 +307,55 @@ export function redactSecrets(text: string): string {
220
307
  return redactGoogleTokens(redactSharedSecrets(text));
221
308
  }
222
309
 
310
+ // A JSON string value that is ENTIRELY standard/URL-safe base64 (plus padding),
311
+ // and long enough to be a payload rather than a flag. Anything else — a path, a
312
+ // MIME type, a sentence, an OAuth token sitting in prose — fails this and is
313
+ // redacted normally, which is what keeps the exemption from becoming a hole.
314
+ const OPAQUE_FIELD_VALUE = '[A-Za-z0-9+/_-]{16,}={0,2}';
315
+
316
+ // Placeholder standing in for a lifted value while redaction runs.
317
+ //
318
+ // NUL-delimited because NUL cannot occur in gog's output: stdout is decoded as
319
+ // UTF-8 text and JSON escapes it as a backslash-u escape, so the placeholder can never
320
+ // collide with real content the way a printable sentinel could. The body
321
+ // contains no character any redaction pattern keys on, and the index keeps each
322
+ // one unique so two blobs can never be swapped on restore.
323
+ const opaquePlaceholder = (i: number): string => `\u0000gogOpaque${i}\u0000`;
324
+
325
+ /**
326
+ * Redact `text` while leaving the values of `fields` untouched.
327
+ *
328
+ * Lift each `"field":"<base64>"` value out to a placeholder, redact what
329
+ * remains, then put the values back. Splicing rather than parsing keeps this on
330
+ * the raw string: `run()` returns text, gog's output is not always JSON, and a
331
+ * parse/re-serialize round trip would rewrite key order and number formatting
332
+ * in output the caller may be matching on.
333
+ */
334
+ export function redactPreservingOpaqueFields(
335
+ text: string,
336
+ fields: readonly string[],
337
+ redact: (input: string) => string,
338
+ ): string {
339
+ const lifted: string[] = [];
340
+ let staged = text;
341
+ for (const field of fields) {
342
+ // The key is escaped because it reaches a RegExp; the value class is fixed
343
+ // above, so a base64 payload can never terminate its own string early.
344
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
345
+ const re = new RegExp(`("${escaped}"\\s*:\\s*")(${OPAQUE_FIELD_VALUE})(")`, 'g');
346
+ staged = staged.replace(re, (_m, open: string, value: string, close: string) => {
347
+ lifted.push(value);
348
+ return `${open}${opaquePlaceholder(lifted.length - 1)}${close}`;
349
+ });
350
+ }
351
+ if (lifted.length === 0) return redact(text);
352
+ let redacted = redact(staged);
353
+ lifted.forEach((value, i) => {
354
+ redacted = redacted.split(opaquePlaceholder(i)).join(value);
355
+ });
356
+ return redacted;
357
+ }
358
+
223
359
  // MCP desktop clients often spawn servers with a stripped PATH that excludes
224
360
  // Homebrew, user-local, and Go's default install dirs — so even when gog is
225
361
  // installed, the spawned server can't find it. Augment the child's PATH with
@@ -266,7 +402,7 @@ async function spawnWithTempFiles(
266
402
  args: GogArg[],
267
403
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
268
404
  ): Promise<string> {
269
- const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
405
+ const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
270
406
  const { tmpdir } = await import('node:os');
271
407
 
272
408
  // mkdtemp creates the directory with mode 0700 (owner-only) on POSIX, so the
@@ -275,17 +411,30 @@ async function spawnWithTempFiles(
275
411
  const dir = await mkdtemp(join(tmpdir(), 'gogcli-mcp-'));
276
412
  try {
277
413
  const argv: string[] = [];
414
+ let seq = 0;
278
415
  for (const arg of args) {
279
416
  if (!isGogFileArg(arg)) {
280
417
  argv.push(arg);
281
418
  continue;
282
419
  }
283
- // Name the file after the flag: one command can carry two payloads
284
- // (e.g. --body-file and --signature-file, both .txt), and a fixed
285
- // basename would have the second silently clobber the first.
286
- const path = join(dir, `${arg.flag}.${arg.ext ?? 'txt'}`);
287
- await writeFile(path, arg.contents, { encoding: 'utf8', mode: 0o600 });
288
- argv.push(`--${arg.flag}=${path}`);
420
+ // Each payload gets its own numbered SUBDIRECTORY, so the basename is free
421
+ // to be whatever the caller needs without any risk of one payload
422
+ // clobbering another. That matters twice over now: `--attach` is
423
+ // repeatable, so a single send can carry several files whose real names
424
+ // are chosen by the caller and may well collide (two `chart.png`s from
425
+ // different folders), and an attachment's basename is what the recipient
426
+ // sees, so it cannot be uniquified by mangling it.
427
+ const sub = join(dir, String(seq));
428
+ seq += 1;
429
+ await mkdir(sub, { recursive: true, mode: 0o700 });
430
+ const path = join(sub, arg.filename ?? `${arg.flag}.${arg.ext ?? 'txt'}`);
431
+ // 'base64' decodes to the real bytes; 'utf8' writes the string as-is,
432
+ // which is the pre-existing behaviour for every text payload.
433
+ const data = arg.encoding === 'base64'
434
+ ? Buffer.from(arg.contents, 'base64')
435
+ : Buffer.from(arg.contents, 'utf8');
436
+ await writeFile(path, data, { mode: 0o600 });
437
+ argv.push(arg.positional ? path : `--${arg.flag}=${path}`);
289
438
  }
290
439
  return await spawnGog(argv, opts);
291
440
  } finally {
@@ -406,8 +555,14 @@ function assembleArgs(
406
555
  }
407
556
 
408
557
  export async function run(args: GogArg[], options: RunOptions = {}): Promise<string> {
409
- const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full' } = options;
410
- const redact = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
558
+ const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields } = options;
559
+ const base = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
560
+ // Only OUTPUT carries opaque payloads. An error message is prose by
561
+ // definition, so it always takes the plain redactor — exempting a field there
562
+ // would be exempting exactly the text a leaked token would appear in.
563
+ const redact = opaqueFields?.length
564
+ ? (text: string): string => redactPreservingOpaqueFields(text, opaqueFields, base)
565
+ : base;
411
566
 
412
567
  const fullArgs = assembleArgs(args, { account, interactive, readonly });
413
568
 
@@ -433,7 +588,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
433
588
  // A thrown non-Error would make `.message` undefined and redact() blow up
434
589
  // with a TypeError, masking the real failure. Same instanceof guard the
435
590
  // codebase already uses in errorText() (tools/utils.ts).
436
- const message = redact(err instanceof Error ? err.message : String(err));
591
+ const message = base(err instanceof Error ? err.message : String(err));
437
592
  // Redaction must not cost the error its TYPE. `RunnerTransportError` is the
438
593
  // structural claim "this failure was ours, not Google's"; flattening it to a
439
594
  // bare Error here would put diagnose() straight back to guessing from prose,
package/src/server.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ToolRegistrar } from '@chrischall/mcp-utils';
2
2
  import { registerApiTools } from './tools/api.js';
3
+ import { registerAppScriptTools } from './tools/appscript.js';
3
4
  import { registerAuthTools, authToolsFor } from './tools/auth.js';
4
5
  import { registerCalendarTools } from './tools/calendar.js';
6
+ import { registerChatTools } from './tools/chat.js';
5
7
  import { registerClassroomTools } from './tools/classroom.js';
6
8
  import { registerContactsTools } from './tools/contacts.js';
7
9
  import { registerDocsTools } from './tools/docs.js';
@@ -23,8 +25,10 @@ export const VERSION = typeof GOGCLI_VERSION !== 'undefined' ? GOGCLI_VERSION :
23
25
  // re-exported below.
24
26
  export const BASE_TOOL_REGISTRARS: ToolRegistrar[] = [
25
27
  registerApiTools,
28
+ registerAppScriptTools,
26
29
  registerAuthTools,
27
30
  registerCalendarTools,
31
+ registerChatTools,
28
32
  registerClassroomTools,
29
33
  registerContactsTools,
30
34
  registerDocsTools,
@@ -37,9 +41,11 @@ export const BASE_TOOL_REGISTRARS: ToolRegistrar[] = [
37
41
 
38
42
  export {
39
43
  registerApiTools,
44
+ registerAppScriptTools,
40
45
  registerAuthTools,
41
46
  authToolsFor,
42
47
  registerCalendarTools,
48
+ registerChatTools,
43
49
  registerClassroomTools,
44
50
  registerContactsTools,
45
51
  registerDocsTools,
@@ -0,0 +1,173 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import {
4
+ accountParam,
5
+ runOrDiagnose,
6
+ registerRunTool,
7
+ paginationParams,
8
+ pushPaginationFlags,
9
+ } from './utils.js';
10
+
11
+ // Google Apps Script (gog >= 0.38.0 for pull/deployments/versions).
12
+ //
13
+ // This wraps the READ and RUN halves of the Apps Script API. gog has no push,
14
+ // so nothing here can change a project's code: `create` makes an empty project
15
+ // and `pull`/`content` only copy code outwards. The one tool with real reach is
16
+ // gog_appscript_run_function, which executes somebody's script under this
17
+ // account's authority — see its description.
18
+ //
19
+ // The Apps Script API is OFF by default on a Google Cloud project, so the first
20
+ // call on a fresh OAuth client fails with "Apps Script API is not enabled for
21
+ // this OAuth project" and a console URL. That error is gog's, it names the
22
+ // exact project, and it is not a scope failure — do not answer it with a
23
+ // re-auth.
24
+ export function registerAppScriptTools(server: McpServer): void {
25
+ const scriptIdParam = z.string().describe(
26
+ 'Apps Script project ID — the long ID in script.google.com/…/projects/<scriptId>/…, NOT the Drive file ID of a '
27
+ + 'container document',
28
+ );
29
+ const apiEnableNote =
30
+ ' Needs the Apps Script API enabled on the OAuth client\'s Google Cloud project; if it is not, gog says so and prints '
31
+ + 'the console URL to enable it. That is a project setting, not a missing scope — re-authorizing will not fix it.';
32
+
33
+ server.registerTool('gog_appscript_get', {
34
+ description:
35
+ 'Get an Apps Script project\'s metadata: title, creator, create/update times, and the parent Drive file when the '
36
+ + 'project is bound to a Sheet, Doc or Form. Use gog_appscript_content to read the actual code.' + apiEnableNote,
37
+ annotations: { readOnlyHint: true },
38
+ inputSchema: {
39
+ scriptId: scriptIdParam,
40
+ account: accountParam,
41
+ },
42
+ }, async ({ scriptId, account }) => {
43
+ return runOrDiagnose(['appscript', 'get', scriptId], { account });
44
+ });
45
+
46
+ server.registerTool('gog_appscript_content', {
47
+ description:
48
+ 'Read a project\'s source — every .gs file and its appsscript.json manifest — INLINE in the response. This is the '
49
+ + 'tool to reach for when the question is "what does this script do"; it needs no filesystem, so it works the same '
50
+ + 'on a hosted deployment as it does locally, unlike gog_appscript_pull.' + apiEnableNote,
51
+ annotations: { readOnlyHint: true },
52
+ inputSchema: {
53
+ scriptId: scriptIdParam,
54
+ account: accountParam,
55
+ },
56
+ }, async ({ scriptId, account }) => {
57
+ return runOrDiagnose(['appscript', 'content', scriptId], { account });
58
+ });
59
+
60
+ server.registerTool('gog_appscript_pull', {
61
+ description:
62
+ 'Write a project\'s files into a local directory, for editing a script as ordinary files. '
63
+ + 'THE DIRECTORY IS RESOLVED WHERE GOG RUNS, which is the caller\'s own machine only on a local (stdio) deployment: '
64
+ + 'on the hosted connector, or any GOG_RUNNER_URL backend, the files land on that server where the caller cannot '
65
+ + 'reach them. Use gog_appscript_content there instead — it returns the same source in the response. Existing files '
66
+ + 'are left alone unless overwrite is set. Read-only as far as Google is concerned: nothing is pushed back.'
67
+ + apiEnableNote,
68
+ inputSchema: {
69
+ scriptId: scriptIdParam,
70
+ dir: z.string().describe('Destination directory, resolved on the machine where gog runs'),
71
+ overwrite: z.boolean().optional().describe('Overwrite files that already exist in dir'),
72
+ account: accountParam,
73
+ },
74
+ }, async ({ scriptId, dir, overwrite, account }) => {
75
+ const args = ['appscript', 'pull', scriptId, dir];
76
+ if (overwrite) args.push('--overwrite');
77
+ return runOrDiagnose(args, { account });
78
+ });
79
+
80
+ server.registerTool('gog_appscript_create', {
81
+ description:
82
+ 'Create a new, empty Apps Script project. Pass parentId to bind it to a Drive file (a Sheet, Doc or Form), which is '
83
+ + 'what makes the script a container-bound script with access to that document; omit it for a standalone project. '
84
+ + 'gog cannot upload code, so the project starts empty either way.' + apiEnableNote,
85
+ inputSchema: {
86
+ title: z.string().describe('Project title'),
87
+ parentId: z.string().optional().describe('Drive file ID to bind the project to (Sheet, Doc or Form). Omit for a standalone project.'),
88
+ account: accountParam,
89
+ },
90
+ }, async ({ title, parentId, account }) => {
91
+ const args = ['appscript', 'create', `--title=${title}`];
92
+ if (parentId) args.push(`--parent-id=${parentId}`);
93
+ return runOrDiagnose(args, { account });
94
+ });
95
+
96
+ server.registerTool('gog_appscript_deployments', {
97
+ description:
98
+ 'List a project\'s deployments — the published web apps, add-ons and API executables, each pinned to a version. A '
99
+ + 'deployment ID from here is what gog_appscript_run_function needs when a script is not running in dev mode.'
100
+ + apiEnableNote,
101
+ annotations: { readOnlyHint: true },
102
+ inputSchema: {
103
+ scriptId: scriptIdParam,
104
+ ...paginationParams,
105
+ account: accountParam,
106
+ },
107
+ }, async ({ scriptId, max, pageToken, page, all, account }) => {
108
+ const args = ['appscript', 'deployments', scriptId];
109
+ pushPaginationFlags(args, { max, pageToken, page, all });
110
+ return runOrDiagnose(args, { account });
111
+ });
112
+
113
+ server.registerTool('gog_appscript_versions', {
114
+ description:
115
+ 'List a project\'s saved versions — the immutable snapshots deployments point at, with their numbers and '
116
+ + 'descriptions. Useful for answering "what is actually deployed" next to gog_appscript_deployments.' + apiEnableNote,
117
+ annotations: { readOnlyHint: true },
118
+ inputSchema: {
119
+ scriptId: scriptIdParam,
120
+ ...paginationParams,
121
+ account: accountParam,
122
+ },
123
+ }, async ({ scriptId, max, pageToken, page, all, account }) => {
124
+ const args = ['appscript', 'versions', scriptId];
125
+ pushPaginationFlags(args, { max, pageToken, page, all });
126
+ return runOrDiagnose(args, { account });
127
+ });
128
+
129
+ server.registerTool('gog_appscript_run_function', {
130
+ description:
131
+ 'Execute a function in a deployed Apps Script project. TREAT THIS AS ARBITRARY CODE EXECUTION: the script runs with '
132
+ + 'this Google account\'s authority and can send mail, edit Drive files or call external services, and the wrapper '
133
+ + 'cannot tell a read from a write — read the code with gog_appscript_content first if you did not write it. '
134
+ + 'Requires the project to be deployed as an API executable and to share the OAuth client with the calling '
135
+ + 'credentials, otherwise Google refuses regardless of scopes. devMode runs the latest saved code instead of the '
136
+ + 'deployed version, and only works if the account owns the script. '
137
+ + 'This is NOT the escape hatch — gog_appscript_run is that.' + apiEnableNote,
138
+ annotations: { destructiveHint: true },
139
+ inputSchema: {
140
+ scriptId: scriptIdParam,
141
+ functionName: z.string().describe('Name of the function to call, e.g. "doWork"'),
142
+ params: z.string().optional().describe('Function parameters as a JSON ARRAY of positional arguments, e.g. \'["a", 1]\' — not an object'),
143
+ devMode: z.boolean().optional().describe('Run the latest saved code rather than the deployed version (owner only)'),
144
+ account: accountParam,
145
+ },
146
+ }, async ({ scriptId, functionName, params, devMode, account }) => {
147
+ // gog passes --params through to the API as-is, so a malformed value comes
148
+ // back as a Google error about the request body rather than about the
149
+ // argument the caller actually got wrong. Checking the shape here is what
150
+ // turns "invalid argument" into "params must be a JSON array".
151
+ if (params !== undefined) {
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = JSON.parse(params);
155
+ } catch {
156
+ throw new Error(`params must be a JSON array of positional arguments, e.g. '["a", 1]'. Received: ${params}`);
157
+ }
158
+ if (!Array.isArray(parsed)) {
159
+ throw new Error(`params must be a JSON ARRAY of positional arguments, e.g. '["a", 1]' — Apps Script takes positional arguments, not named ones. Received: ${params}`);
160
+ }
161
+ }
162
+ const args = ['appscript', 'run', scriptId, functionName];
163
+ if (params !== undefined) args.push(`--params=${params}`);
164
+ if (devMode) args.push('--dev-mode');
165
+ return runOrDiagnose(args, { account });
166
+ });
167
+
168
+ registerRunTool(server, {
169
+ service: 'appscript',
170
+ examples: '"get", "content", "deployments"',
171
+ note: 'To execute a function, use gog_appscript_run_function — this tool is the generic escape hatch.',
172
+ });
173
+ }
@@ -3,6 +3,56 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
4
4
  import { annotateTruncatedList } from '../pagination.js';
5
5
 
6
+ // Reminder params, shared by create and update (gog >= 0.38.0 for
7
+ // --no-reminders). An event's reminders are one of THREE states, and the two
8
+ // params below have to spell all three because gog does:
9
+ //
10
+ // reminders: ['popup:30m'] → --reminder=popup:30m custom overrides
11
+ // noReminders: true → --no-reminders no reminder at all
12
+ // reminders: [] → --reminder= back to the calendar's defaults
13
+ //
14
+ // That last one is the subtle one and it only means anything on update: gog
15
+ // reads an EMPTY --reminder as "clear the overrides and use the calendar
16
+ // default" (openclaw/gogcli#1016), which is a different outcome from omitting
17
+ // the flag (leave whatever the event already has) and from --no-reminders
18
+ // (override the calendar with silence). An empty array is how a JSON caller
19
+ // says it, since there is no way to send a bare flag with no value.
20
+ const reminderParams = {
21
+ reminders: z.array(z.string()).max(5).optional().describe(
22
+ 'Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d '
23
+ + '(max 40320 minutes = 4 weeks). Google allows at most 5. These REPLACE the event\'s reminders — on update, pass an '
24
+ + 'EMPTY array to drop custom reminders and go back to the calendar\'s defaults. Cannot be combined with noReminders.',
25
+ ),
26
+ noReminders: z.boolean().optional().describe(
27
+ 'Give the event no reminders at all, overriding the calendar\'s defaults. Different from an empty reminders array, '
28
+ + 'which RESTORES those defaults. Cannot be combined with reminders.',
29
+ ),
30
+ };
31
+
32
+ // The one place the three states become argv. Kept together so create and
33
+ // update cannot drift apart on the empty-array case.
34
+ function pushReminderFlags(
35
+ args: string[],
36
+ p: { reminders?: string[]; noReminders?: boolean },
37
+ ): void {
38
+ if (p.noReminders) {
39
+ // gog's own flags are `xor:"reminders"`, so it would reject this too — but
40
+ // only after a spawn, and with kong's wording rather than the tool's.
41
+ if (p.reminders !== undefined) {
42
+ throw new Error('reminders and noReminders are mutually exclusive: pass reminders to set custom ones, noReminders for none, or an empty reminders array to restore the calendar defaults.');
43
+ }
44
+ args.push('--no-reminders');
45
+ return;
46
+ }
47
+ if (p.reminders === undefined) return;
48
+ // Empty array → one empty --reminder, which is gog's "restore defaults".
49
+ if (p.reminders.length === 0) {
50
+ args.push('--reminder=');
51
+ return;
52
+ }
53
+ for (const reminder of p.reminders) args.push(`--reminder=${reminder}`);
54
+ }
55
+
6
56
  export function registerCalendarTools(server: McpServer): void {
7
57
  server.registerTool('gog_calendar_events', {
8
58
  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): '
@@ -80,9 +130,10 @@ export function registerCalendarTools(server: McpServer): void {
80
130
  allDay: z.boolean().optional().describe('All-day event (use date-only in from/to)'),
81
131
  timezone: z.string().optional().describe('IANA timezone metadata applied to from/to (e.g. America/New_York). Sets both start and end timezone unless start/end timezone are overridden.'),
82
132
  withZoom: z.boolean().optional().describe('Create a Zoom video conference for this event (requires Zoom S2S OAuth setup)'),
133
+ ...reminderParams,
83
134
  account: accountParam,
84
135
  },
85
- }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, account }) => {
136
+ }, async ({ calendarId, summary, from, to, description, location, attendees, allDay, timezone, withZoom, reminders, noReminders, account }) => {
86
137
  const args = ['calendar', 'create', calendarId, `--summary=${summary}`, `--from=${from}`, `--to=${to}`];
87
138
  if (description) args.push(`--description=${description}`);
88
139
  if (location) args.push(`--location=${location}`);
@@ -90,6 +141,7 @@ export function registerCalendarTools(server: McpServer): void {
90
141
  if (allDay) args.push('--all-day');
91
142
  if (timezone) args.push(`--timezone=${timezone}`);
92
143
  if (withZoom) args.push('--with-zoom');
144
+ pushReminderFlags(args, { reminders, noReminders });
93
145
  return runOrDiagnose(args, { account });
94
146
  });
95
147
 
@@ -111,9 +163,10 @@ export function registerCalendarTools(server: McpServer): void {
111
163
  regenerateZoom: z.boolean().optional().describe('Replace the event\'s existing Zoom video conference'),
112
164
  removeZoom: z.boolean().optional().describe('Remove the event\'s Zoom video conference'),
113
165
  removeMeet: z.boolean().optional().describe('Remove the event\'s Google Meet video conference (clears conference data only)'),
166
+ ...reminderParams,
114
167
  account: accountParam,
115
168
  },
116
- }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, account }) => {
169
+ }, async ({ calendarId, eventId, summary, from, to, description, location, attendees, addAttendees, attachments, withZoom, regenerateZoom, removeZoom, removeMeet, reminders, noReminders, account }) => {
117
170
  const args = ['calendar', 'update', calendarId, eventId];
118
171
  if (summary !== undefined) args.push(`--summary=${summary}`);
119
172
  if (from !== undefined) args.push(`--from=${from}`);
@@ -127,6 +180,7 @@ export function registerCalendarTools(server: McpServer): void {
127
180
  if (regenerateZoom) args.push('--regenerate-zoom');
128
181
  if (removeZoom) args.push('--remove-zoom');
129
182
  if (removeMeet) args.push('--remove-meet');
183
+ pushReminderFlags(args, { reminders, noReminders });
130
184
  return runOrDiagnose(args, { account });
131
185
  });
132
186