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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +9 -4
- package/SKILL.md +10 -5
- package/dist/index.js +525 -57
- package/dist/lib.js +457 -18
- package/manifest.json +82 -2
- package/mint.yaml +106 -0
- package/package.json +5 -5
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/gmail-results.ts +29 -2
- package/src/lib.ts +16 -0
- package/src/runner.ts +170 -15
- package/src/server.ts +6 -0
- package/src/tools/appscript.ts +173 -0
- package/src/tools/calendar.ts +56 -2
- package/src/tools/chat.ts +253 -0
- package/src/tools/gmail.ts +17 -3
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/gmail-results.test.ts +47 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/server.test.ts +2 -0
- package/tests/tools/appscript.test.ts +159 -0
- package/tests/tools/calendar.test.ts +121 -0
- package/tests/tools/chat.test.ts +284 -0
- package/tests/tools/gmail.test.ts +89 -0
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/src/tools/calendar.ts
CHANGED
|
@@ -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
|
|