gogcli-mcp 2.25.0 → 2.27.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/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.25.0",
6
+ "version": "2.27.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -112,6 +112,14 @@
112
112
  "name": "gog_gmail_send",
113
113
  "description": "Send an email, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
114
114
  },
115
+ {
116
+ "name": "gog_gmail_reply",
117
+ "description": "Reply to a Gmail message (sender only), inheriting the \"Re:\" subject and quoting the original below your body"
118
+ },
119
+ {
120
+ "name": "gog_gmail_reply_all",
121
+ "description": "Reply to every participant of a Gmail message (sender plus all To/Cc), with the same inherited subject and quoted original"
122
+ },
115
123
  {
116
124
  "name": "gog_gmail_run",
117
125
  "description": "Run any gog gmail subcommand (escape hatch)"
@@ -423,6 +431,86 @@
423
431
  {
424
432
  "name": "gog_slides_run",
425
433
  "description": "Run any gog slides subcommand (escape hatch)"
434
+ },
435
+ {
436
+ "name": "gog_chat_spaces_list",
437
+ "description": "List the Google Chat spaces the account belongs to, with their resource names"
438
+ },
439
+ {
440
+ "name": "gog_chat_spaces_find",
441
+ "description": "Find Chat spaces by display name (substring by default, or exact)"
442
+ },
443
+ {
444
+ "name": "gog_chat_spaces_create",
445
+ "description": "Create a named Chat space, optionally seeding its membership"
446
+ },
447
+ {
448
+ "name": "gog_chat_threads_list",
449
+ "description": "List the threads in a Chat space"
450
+ },
451
+ {
452
+ "name": "gog_chat_messages_list",
453
+ "description": "Read messages in a space, with mentions and reaction summaries"
454
+ },
455
+ {
456
+ "name": "gog_chat_messages_send",
457
+ "description": "Post a message to a Chat space (immediately visible; supports attachments)"
458
+ },
459
+ {
460
+ "name": "gog_chat_dm_send",
461
+ "description": "Send a direct message to one person by email address"
462
+ },
463
+ {
464
+ "name": "gog_chat_dm_space",
465
+ "description": "Resolve the DM space resource name for an email address"
466
+ },
467
+ {
468
+ "name": "gog_chat_reactions_list",
469
+ "description": "List the emoji reactions on a message, with who reacted"
470
+ },
471
+ {
472
+ "name": "gog_chat_reactions_create",
473
+ "description": "React to a Chat message with an emoji"
474
+ },
475
+ {
476
+ "name": "gog_chat_reactions_delete",
477
+ "description": "Remove one emoji reaction by its resource name"
478
+ },
479
+ {
480
+ "name": "gog_chat_run",
481
+ "description": "Run any gog chat subcommand not covered by the other tools"
482
+ },
483
+ {
484
+ "name": "gog_appscript_get",
485
+ "description": "Get an Apps Script project's metadata"
486
+ },
487
+ {
488
+ "name": "gog_appscript_content",
489
+ "description": "Read a project's source files and manifest inline"
490
+ },
491
+ {
492
+ "name": "gog_appscript_pull",
493
+ "description": "Write a project's files into a directory on the machine where gog runs"
494
+ },
495
+ {
496
+ "name": "gog_appscript_create",
497
+ "description": "Create a new, empty Apps Script project (optionally bound to a Drive file)"
498
+ },
499
+ {
500
+ "name": "gog_appscript_deployments",
501
+ "description": "List a project's deployments"
502
+ },
503
+ {
504
+ "name": "gog_appscript_versions",
505
+ "description": "List a project's saved versions"
506
+ },
507
+ {
508
+ "name": "gog_appscript_run_function",
509
+ "description": "Execute a function in a deployed Apps Script project"
510
+ },
511
+ {
512
+ "name": "gog_appscript_run",
513
+ "description": "Run any gog appscript subcommand not covered by the other tools"
426
514
  }
427
515
  ],
428
516
  "compatibility": {
package/mint.yaml ADDED
@@ -0,0 +1,106 @@
1
+ version: 1
2
+ name: gogcli (Google Workspace)
3
+ slug: gogcli-mcp
4
+ summary: >-
5
+ MCP server wrapping gogcli for Google service access
6
+ command:
7
+ # This package publishes a single bin; naming it keeps the install
8
+ # unambiguous alongside its eight sibling packages.
9
+ bin: gogcli-mcp
10
+ env:
11
+ - name: GOG_CLIENT_ID
12
+ required: false
13
+ help: >-
14
+ Google OAuth client id. NOTE: on the local-spawn path the child `gog`
15
+ receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
16
+ *_TOKEN / *_SECRET / *_API_KEY / *_PRIVATE_KEY variable — so the CLI
17
+ authenticates from its own stored credentials under $HOME (see
18
+ state.dataDir), not from these variables being passed through.
19
+ - name: GOG_CLIENT_SECRET
20
+ secret: true
21
+ required: false
22
+ help: >-
23
+ Google OAuth client secret. Stripped from the spawned CLI's environment
24
+ by runner.ts's *_SECRET rule — see GOG_CLIENT_ID.
25
+ - name: GOG_REFRESH_TOKEN
26
+ secret: true
27
+ required: false
28
+ help: >-
29
+ Google OAuth refresh token. Stripped from the spawned CLI's environment
30
+ by runner.ts's *_TOKEN rule — see GOG_CLIENT_ID. A hosted deployment must
31
+ therefore carry gog's authorised-account state in its persisted data dir,
32
+ or drive a remote runner via GOG_RUNNER_URL.
33
+ - name: GOG_ACCESS_TOKEN
34
+ secret: true
35
+ required: false
36
+ help: >-
37
+ Deliberately removed from the spawned CLI's environment by runner.ts, so
38
+ that a stale directly-passed token cannot shadow gog's stored refresh
39
+ credential. Leave unset.
40
+ - name: GOG_ACCOUNT
41
+ required: false
42
+ help: >-
43
+ Which configured Google account to act as, when more than one is
44
+ authorised. Defaults to the single/most recent account.
45
+ - name: GOG_READONLY
46
+ required: false
47
+ help: >-
48
+ Set to 1 to refuse every mutating operation. Recommended when the
49
+ connector is shared or you only need reads.
50
+ - name: GOG_PATH
51
+ required: false
52
+ help: >-
53
+ Path to the `gog` binary. Leave unset when the dependency below supplies
54
+ it; set it only to point at a binary you manage yourself.
55
+ - name: GOG_RUNNER_URL
56
+ required: false
57
+ help: >-
58
+ URL of a remote gog runner to execute against instead of spawning the
59
+ local binary. If you set this, add its host to egress.allow.
60
+ - name: GOG_RUNNER_KEY
61
+ secret: true
62
+ required: false
63
+ help: >-
64
+ Shared key authenticating calls to GOG_RUNNER_URL. Required whenever that
65
+ is set.
66
+ - name: GOG_TIMEZONE
67
+ required: false
68
+ help: >-
69
+ The IANA zone gog itself formats its naive timestamps in. The wrapper
70
+ reads it (naiveSourceTimeZone) to re-attach the correct offset, so it
71
+ should match gog's own configuration. Falls back to DISPLAY_TZ, then to
72
+ America/New_York.
73
+ - name: DISPLAY_TZ
74
+ required: false
75
+ help: >-
76
+ The IANA zone every rendered *Display field uses (displayTimeZone,
77
+ default America/New_York). It does not read GOG_TIMEZONE — the fallback
78
+ runs the other way, so this is also what GOG_TIMEZONE falls back to. An
79
+ invalid value degrades to the default rather than throwing.
80
+ dependencies:
81
+ # Every tool shells out to the `gog` CLI; without it the server starts and
82
+ # then fails on the first call. This tag must track
83
+ # packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
84
+ # assume) and the fly-gog-runner/Dockerfile GOG_VERSION build arg. See
85
+ # CLAUDE.md "Required gog version" — bumping the floor means bumping these
86
+ # nine pins too.
87
+ - kind: github-release
88
+ repo: openclaw/gogcli
89
+ tag: v0.38.1
90
+ asset: "gogcli_*_linux_amd64.tar.gz"
91
+ bin: [gog]
92
+ state:
93
+ dataDir: true
94
+ reason: >-
95
+ `gog` keeps its authorised-account state and token cache under $HOME.
96
+ Without a persistent data dir every cold start has no account configured and
97
+ every tool call fails until the credentials are re-supplied.
98
+ egress:
99
+ allow:
100
+ # Google OAuth token exchange and the Google REST APIs the CLI calls.
101
+ - oauth2.googleapis.com
102
+ - www.googleapis.com
103
+ - googleapis.com
104
+ #
105
+ # NOTE: if you set GOG_RUNNER_URL to run against a remote gog runner, add
106
+ # that host here too — it is supplied at runtime and cannot be declared.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.25.0",
3
+ "version": "2.27.0",
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,15 +41,15 @@
41
41
  "test:coverage": "vitest run --coverage"
42
42
  },
43
43
  "dependencies": {
44
- "@chrischall/mcp-utils": "^0.14.0",
44
+ "@chrischall/mcp-utils": "^0.15.0",
45
45
  "@modelcontextprotocol/sdk": "^1.30.0",
46
46
  "zod": "^4.4.3"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^26.1.2",
49
+ "@types/node": "^26.2.0",
50
50
  "@vitest/coverage-v8": "^4.1.8",
51
- "esbuild": "^0.28.1",
51
+ "esbuild": "^0.28.2",
52
52
  "typescript": "^7.0.2",
53
- "vitest": "^4.1.10"
53
+ "vitest": "^4.1.11"
54
54
  }
55
55
  }
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.25.0",
10
+ "version": "2.27.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.25.0",
15
+ "version": "2.27.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -177,6 +177,16 @@ export async function finalizeGmailSearch(
177
177
  // The merged payload keeps a nextPageToken only if pages remain when the cap is
178
178
  // hit, so finalizeGmailSearch still marks it truncated — running out of budget
179
179
  // is not the same as reaching the end, and must not read like it.
180
+ //
181
+ // A REPEATED CURSOR ENDS THE WALK TOO. Google can hand back a nextPageToken it
182
+ // has already issued, and gog 0.38.0 fixed exactly that for its own `--all`
183
+ // (openclaw/gogcli#1004) — but this walk is not gog's: it makes N separate
184
+ // single-page calls, so a repeated cursor arrives here untouched. Following it
185
+ // re-fetches a page already merged, so the caller gets the same threads twice
186
+ // over, presented as more results and looking perfectly well-formed. The token
187
+ // is KEPT when that happens, for the same reason the cap keeps it: the walk
188
+ // never established an end, and a set that reads complete is precisely how mail
189
+ // that exists gets reported as missing.
180
190
  export async function fetchGmailPages(
181
191
  runPage: (token: string | undefined) => Promise<CallToolResult>,
182
192
  itemsKey: 'threads' | 'messages',
@@ -186,6 +196,10 @@ export async function fetchGmailPages(
186
196
  const merged: unknown[] = [];
187
197
  let base: Record<string, unknown> | undefined;
188
198
  let token = startToken;
199
+ // Every cursor this walk has already fetched with, so one Google repeats is
200
+ // recognised rather than followed. Seeded with the caller's own cursor: a
201
+ // response echoing that back would re-fetch the page just merged.
202
+ const fetched = new Set<string>(startToken === undefined ? [] : [startToken]);
189
203
 
190
204
  for (let pages = 0; pages < maxPages; pages++) {
191
205
  const result = await runPage(token);
@@ -199,10 +213,23 @@ export async function fetchGmailPages(
199
213
  }
200
214
  base = parsed;
201
215
  merged.push(...(parsed[itemsKey] as unknown[]));
202
- token = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
216
+ const next = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
203
217
  ? parsed.nextPageToken
204
218
  : undefined;
205
- if (token === undefined) break;
219
+ if (next === undefined) {
220
+ token = undefined;
221
+ break;
222
+ }
223
+ // Checked BEFORE the next fetch, so the duplicate page is never requested
224
+ // and never merged. `token` keeps the repeated cursor so the result still
225
+ // reads as truncated — it is the honest answer, and handing it back lets a
226
+ // caller retry later rather than concluding there is nothing more.
227
+ if (fetched.has(next)) {
228
+ token = next;
229
+ break;
230
+ }
231
+ fetched.add(next);
232
+ token = next;
206
233
  }
207
234
 
208
235
  return finish(base as Record<string, unknown>, itemsKey, merged, token);
package/src/lib.ts CHANGED
@@ -2,9 +2,11 @@ export {
2
2
  BASE_TOOL_REGISTRARS,
3
3
  VERSION,
4
4
  registerApiTools,
5
+ registerAppScriptTools,
5
6
  registerAuthTools,
6
7
  authToolsFor,
7
8
  registerCalendarTools,
9
+ registerChatTools,
8
10
  registerClassroomTools,
9
11
  registerContactsTools,
10
12
  registerDocsTools,
@@ -14,6 +16,11 @@ export {
14
16
  registerSlidesTools,
15
17
  registerTasksTools,
16
18
  } from './server.js';
19
+ // The reply/reply-all schema and flag builder live in the base package so the
20
+ // gmail sub-package's draft-side twins reuse ONE definition — registering the
21
+ // same tool name from both registrar lists would be a duplicate-name error.
22
+ export { replySchema, appendReplyFlags } from './tools/gmail.js';
23
+ export type { ReplyFlags } from './tools/gmail.js';
17
24
  export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './runner.js';
18
25
  // Sub-package tools that read gog JSON through bare `run()` (rather than the
19
26
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
@@ -55,4 +62,5 @@ export {
55
62
  pageAliasParam,
56
63
  resolvePageToken,
57
64
  registerRunTool,
65
+ assertNotBoth,
58
66
  } from './tools/utils.js';
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.37.0';
220
+ export const MIN_GOG_VERSION = '0.38.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/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