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/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.24.0",
6
+ "version": "2.26.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",
@@ -110,7 +110,7 @@
110
110
  },
111
111
  {
112
112
  "name": "gog_gmail_send",
113
- "description": "Send an email"
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
115
  {
116
116
  "name": "gog_gmail_run",
@@ -423,6 +423,86 @@
423
423
  {
424
424
  "name": "gog_slides_run",
425
425
  "description": "Run any gog slides subcommand (escape hatch)"
426
+ },
427
+ {
428
+ "name": "gog_chat_spaces_list",
429
+ "description": "List the Google Chat spaces the account belongs to, with their resource names"
430
+ },
431
+ {
432
+ "name": "gog_chat_spaces_find",
433
+ "description": "Find Chat spaces by display name (substring by default, or exact)"
434
+ },
435
+ {
436
+ "name": "gog_chat_spaces_create",
437
+ "description": "Create a named Chat space, optionally seeding its membership"
438
+ },
439
+ {
440
+ "name": "gog_chat_threads_list",
441
+ "description": "List the threads in a Chat space"
442
+ },
443
+ {
444
+ "name": "gog_chat_messages_list",
445
+ "description": "Read messages in a space, with mentions and reaction summaries"
446
+ },
447
+ {
448
+ "name": "gog_chat_messages_send",
449
+ "description": "Post a message to a Chat space (immediately visible; supports attachments)"
450
+ },
451
+ {
452
+ "name": "gog_chat_dm_send",
453
+ "description": "Send a direct message to one person by email address"
454
+ },
455
+ {
456
+ "name": "gog_chat_dm_space",
457
+ "description": "Resolve the DM space resource name for an email address"
458
+ },
459
+ {
460
+ "name": "gog_chat_reactions_list",
461
+ "description": "List the emoji reactions on a message, with who reacted"
462
+ },
463
+ {
464
+ "name": "gog_chat_reactions_create",
465
+ "description": "React to a Chat message with an emoji"
466
+ },
467
+ {
468
+ "name": "gog_chat_reactions_delete",
469
+ "description": "Remove one emoji reaction by its resource name"
470
+ },
471
+ {
472
+ "name": "gog_chat_run",
473
+ "description": "Run any gog chat subcommand not covered by the other tools"
474
+ },
475
+ {
476
+ "name": "gog_appscript_get",
477
+ "description": "Get an Apps Script project's metadata"
478
+ },
479
+ {
480
+ "name": "gog_appscript_content",
481
+ "description": "Read a project's source files and manifest inline"
482
+ },
483
+ {
484
+ "name": "gog_appscript_pull",
485
+ "description": "Write a project's files into a directory on the machine where gog runs"
486
+ },
487
+ {
488
+ "name": "gog_appscript_create",
489
+ "description": "Create a new, empty Apps Script project (optionally bound to a Drive file)"
490
+ },
491
+ {
492
+ "name": "gog_appscript_deployments",
493
+ "description": "List a project's deployments"
494
+ },
495
+ {
496
+ "name": "gog_appscript_versions",
497
+ "description": "List a project's saved versions"
498
+ },
499
+ {
500
+ "name": "gog_appscript_run_function",
501
+ "description": "Execute a function in a deployed Apps Script project"
502
+ },
503
+ {
504
+ "name": "gog_appscript_run",
505
+ "description": "Run any gog appscript subcommand not covered by the other tools"
426
506
  }
427
507
  ],
428
508
  "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.24.0",
3
+ "version": "2.26.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.24.0",
10
+ "version": "2.26.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.24.0",
15
+ "version": "2.26.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,263 @@
1
+ import { z } from 'zod';
2
+ import type { GogArg, GogFileArg } from './runner.js';
3
+
4
+ /**
5
+ * Caller-supplied attachment BYTES, for hosts that share no filesystem with gog.
6
+ *
7
+ * ## Why this exists
8
+ *
9
+ * Every attachment input gog offers is a PATH — `gmail send --attach`,
10
+ * `gmail drafts create --attach`, `drive upload <localPath>` — and those paths
11
+ * resolve wherever gog runs. On the local stdio transport that is the caller's
12
+ * own machine and everything works. On the hosted connector, and on any host
13
+ * that reaches a backend through GOG_RUNNER_URL, gog runs somewhere else
14
+ * entirely: no path the caller can name exists there, so outbound attachments
15
+ * were simply impossible — including via Drive, whose upload takes a path too.
16
+ *
17
+ * The seam that fixes it already existed. `GogFileArg` writes a payload to a
18
+ * private temp file NEXT TO GOG — on the runner for the remote path, in a
19
+ * mkdtemp dir for the local one — and passes the resulting path. It was built
20
+ * for oversized text (a long HTML mail body) that could not fit in argv; all
21
+ * binary attachments need on top of that is a base64 spelling and control of
22
+ * the basename, both of which are now `GogFileArg` fields.
23
+ *
24
+ * So an inline attachment is not a new transport. It is the same temp-file
25
+ * hand-off, carrying bytes instead of prose.
26
+ */
27
+
28
+ // Ceiling for ONE attachment's decoded bytes.
29
+ //
30
+ // Pinned to the Fly runner's own MAX_FILE_ARG_BYTES (fly-gog-runner/server.mjs)
31
+ // so the two agree. That matters: without a check here the local stdio path
32
+ // would accept any size while the remote path refused at 8 MiB, and the caller
33
+ // would meet the limit as a transport rejection from a layer they cannot see.
34
+ // Checked in the TOOL so the error names the file and the limit instead.
35
+ export const MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
36
+
37
+ // The Fly runner caps an ENTIRE /run request body at 32 MiB
38
+ // (fly-gog-runner/server.mjs MAX_BODY_BYTES). Restated rather than imported:
39
+ // that package is not a dependency of this one, and the Worker bundle must not
40
+ // pull it in. Keep the two in sync.
41
+ const RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
42
+
43
+ // Room inside that body for the JSON structure alone — key names, quoting,
44
+ // commas, the accessToken, and the short flag strings (`--to=…`, `--subject=…`).
45
+ // It does NOT have to cover the mail body: a large body is a GogFileArg, and
46
+ // GogFileArgs are measured explicitly below rather than absorbed here.
47
+ const RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
48
+
49
+ /**
50
+ * How many bytes of PAYLOAD one `/run` request can carry, counted as they are
51
+ * spelled on the wire.
52
+ *
53
+ * The binding constraint on a message is its wire size, not the decoded size of
54
+ * its files, and it is tighter than Gmail's own 25 MB limit: connector-runtime
55
+ * sends every payload inside one `JSON.stringify({ args, accessToken })` body,
56
+ * where binary rides as base64 (4/3 inflation) and text rides verbatim. A limit
57
+ * expressed in decoded bytes must absorb that inflation or it documents a size
58
+ * the runner rejects with "request body too large" — a rejection from a layer
59
+ * the caller cannot see, which is exactly what pinning the per-file ceiling to
60
+ * MAX_FILE_ARG_BYTES exists to prevent.
61
+ */
62
+ export const MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
63
+
64
+ // Ceiling for all inline attachments on one message, in DECODED bytes — the
65
+ // units a caller thinks in, derived from the wire budget above.
66
+ //
67
+ // This is the ceiling for attachments ALONE. Anything else large in the same
68
+ // request spends the same budget — most of all the mail body, which `payloadArg`
69
+ // turns into a GogFileArg past 4 KiB and which then rides in the same JSON body
70
+ // at roughly 1:1. `inlineAttachmentArgs` therefore measures the actual sibling
71
+ // args rather than trusting this number, so a 23 MiB attachment set plus a
72
+ // multi-MiB HTML body is refused here, with an error naming the body, instead of
73
+ // arriving as a bare transport rejection.
74
+ //
75
+ // Neither bound is hypothetical at the edges: three attachments at the
76
+ // documented 8 MiB per-file maximum is 24 MiB, which alone encodes to exactly
77
+ // MAX_BODY_BYTES, leaving nothing for anything else.
78
+ export const MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor((MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3) / 4);
79
+
80
+ /**
81
+ * Bytes one already-assembled arg occupies in the `/run` JSON body.
82
+ *
83
+ * A plain string costs its UTF-8 length; a file arg costs the length of its
84
+ * `contents` as spelled on the wire — the base64 text for binary, the UTF-8
85
+ * text itself otherwise. JSON quoting and key names are covered by the reserve.
86
+ */
87
+ function wireBytesOf(arg: GogArg): number {
88
+ if (typeof arg === 'string') return Buffer.byteLength(arg, 'utf8');
89
+ return arg.encoding === 'base64' ? arg.contents.length : Buffer.byteLength(arg.contents, 'utf8');
90
+ }
91
+
92
+ // FLOOR, never round: this number is published to callers as a limit, so it has
93
+ // to be one they can actually send. Rounding 23.25 MiB up to "24 MiB" would
94
+ // document a size that gets rejected.
95
+ const formatMiB = (bytes: number): string => `${Math.floor(bytes / (1024 * 1024))} MiB`;
96
+
97
+ /** Human-readable ceilings, for tool descriptions — so the docs cannot drift. */
98
+ export const INLINE_ATTACHMENT_LIMITS_TEXT =
99
+ `up to ${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)} per file and ` +
100
+ `${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)} in total`;
101
+
102
+ /**
103
+ * One attachment supplied as bytes rather than as a path.
104
+ *
105
+ * `mimeType` is deliberately ABSENT. gog derives an attachment's MIME type from
106
+ * the filename extension (`mailmime.PrepareAttachments`) and exposes no flag to
107
+ * override it, so a `mimeType` field here could only ever be accepted and
108
+ * ignored. Naming the file `chart.png` is what sets the type; a parameter that
109
+ * silently does nothing is worse than no parameter. (`gog_drive_upload` DOES
110
+ * take a real `mimeType`, because `drive upload --mime-type` exists.)
111
+ */
112
+ export const inlineAttachmentSchema = z.object({
113
+ filename: z.string().min(1).describe(
114
+ 'Filename the recipient will see, e.g. "pendant-layouts.png". gog infers the attachment\'s MIME '
115
+ + 'type from this extension, so give it the right one — a .png sent as "layouts" arrives as an '
116
+ + 'untyped blob. Must be a single filename, not a path.',
117
+ ),
118
+ contentBase64: z.string().min(1).describe(
119
+ 'The file\'s bytes, base64-encoded (standard alphabet, with padding). This is the whole point of '
120
+ + 'this parameter: the bytes travel with the request, so nothing needs to exist on the gog server\'s '
121
+ + 'filesystem.',
122
+ ),
123
+ });
124
+
125
+ export type InlineAttachmentInput = z.infer<typeof inlineAttachmentSchema>;
126
+
127
+ /** Reusable tool parameter — the same field on send, drafts create and update. */
128
+ export const attachInlineParam = z.array(inlineAttachmentSchema).optional().describe(
129
+ 'Attachments supplied as BYTES rather than as server-side paths — use this whenever you hold a file '
130
+ + 'and the gog server does not, which is always the case on the hosted connector and on any remote '
131
+ + `deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). `
132
+ + 'Can be combined with `attach`: the two name disjoint files (paths read on the server vs. bytes sent '
133
+ + 'with the call), and both end up as ordinary attachments on the message.',
134
+ );
135
+
136
+ /**
137
+ * Reject a filename that is a path, a traversal, or otherwise not one plain
138
+ * segment. It becomes both the temp file's basename and the name the recipient
139
+ * sees, so the two things it must not do are escape the temp directory and
140
+ * arrive misleading.
141
+ */
142
+ function validateFilename(filename: string, where: string): void {
143
+ if (/[/\\]/.test(filename)) {
144
+ throw new Error(
145
+ `${where}: filename ${JSON.stringify(filename)} must be a bare filename, not a path. `
146
+ + 'Pass just the name the recipient should see, e.g. "report.pdf".',
147
+ );
148
+ }
149
+ // eslint-disable-next-line no-control-regex
150
+ if (/[\x00-\x1f]/.test(filename) || /^\.+$/.test(filename) || filename.length > 200) {
151
+ throw new Error(
152
+ `${where}: filename ${JSON.stringify(filename)} is not a usable filename `
153
+ + '(no control characters, not "."/"..", 200 characters max).',
154
+ );
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Decoded byte length of a base64 string, or null when it is not valid base64.
160
+ *
161
+ * `Buffer.from(…, 'base64')` never throws — it silently DROPS characters it does
162
+ * not recognise — so a truncated or whitespace-mangled payload would otherwise
163
+ * be written to disk as a corrupt file and mailed out as one. The round trip is
164
+ * what turns that into an error the caller can act on, at the boundary where
165
+ * their input arrived rather than in the recipient's inbox.
166
+ */
167
+ function decodedLength(contentBase64: string): number | null {
168
+ const buf = Buffer.from(contentBase64, 'base64');
169
+ return buf.toString('base64') === contentBase64 ? buf.length : null;
170
+ }
171
+
172
+ /**
173
+ * Validate ONE caller-supplied file and turn it into a `GogFileArg`, which the
174
+ * executor materializes to a temp file beside gog.
175
+ *
176
+ * Throws with an actionable message on anything invalid. The MCP layer turns a
177
+ * thrown handler error into an `isError` result, so every rejection here reaches
178
+ * the caller as a readable sentence naming the file — rather than as a base64
179
+ * decode producing a silently corrupt upload, or as a transport-layer size
180
+ * rejection from a layer the caller cannot see.
181
+ *
182
+ * `positional` emits the path as a bare argv element instead of `--flag=path`,
183
+ * for subcommands that take the file as a positional argument
184
+ * (`gog drive upload <localPath>`).
185
+ *
186
+ * @returns the arg, and the file's decoded size so callers can total it.
187
+ */
188
+ export function inlineFileArg(
189
+ flag: string,
190
+ attachment: InlineAttachmentInput,
191
+ opts: { positional?: boolean; where?: string } = {},
192
+ ): { arg: GogFileArg; bytes: number } {
193
+ const { filename, contentBase64 } = attachment;
194
+ const where = opts.where ?? `attachInline entry ${JSON.stringify(filename)}`;
195
+ validateFilename(filename, where);
196
+ const bytes = decodedLength(contentBase64);
197
+ if (bytes === null) {
198
+ throw new Error(
199
+ `${where}: contents are not valid base64. Send the standard alphabet with padding and no `
200
+ + 'line breaks — the value must survive a decode/re-encode round trip unchanged.',
201
+ );
202
+ }
203
+ if (bytes > MAX_INLINE_ATTACHMENT_BYTES) {
204
+ throw new Error(
205
+ `${where}: ${bytes} bytes exceeds the ${MAX_INLINE_ATTACHMENT_BYTES}-byte `
206
+ + `(${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)}) per-file limit for inline content. `
207
+ + 'Upload it to Drive and link to it instead, or send it from a local (stdio) deployment '
208
+ + 'using a real server-side path.',
209
+ );
210
+ }
211
+ // `filename` (not `ext`) is what makes the delivered copy carry the caller's
212
+ // name: gog reads an attachment's MIME filename, and Drive's default title,
213
+ // off the path it is handed.
214
+ const arg: GogFileArg = { kind: 'file', flag, contents: contentBase64, encoding: 'base64', filename };
215
+ if (opts.positional) arg.positional = true;
216
+ return { arg, bytes };
217
+ }
218
+
219
+ /**
220
+ * Validate inline attachments and turn them into `GogFileArg`s for `flag`
221
+ * (repeatable — one arg per attachment), enforcing the per-message total on top
222
+ * of each file's own ceiling.
223
+ */
224
+ export function inlineAttachmentArgs(
225
+ flag: string,
226
+ attachments: readonly InlineAttachmentInput[] | undefined,
227
+ siblingArgs: readonly GogArg[] = [],
228
+ ): GogArg[] {
229
+ if (!attachments?.length) return [];
230
+ const args: GogArg[] = [];
231
+ // What the rest of the request already spends. Overwhelmingly this is the mail
232
+ // body — small when inline, up to 8 MiB once payloadArg has made it a file arg
233
+ // — and measuring it is what keeps "every input was within its own documented
234
+ // limit" from still adding up to a rejected request.
235
+ const siblingWire = siblingArgs.reduce((sum, arg) => sum + wireBytesOf(arg), 0);
236
+ let attachmentWire = 0;
237
+ let decodedTotal = 0;
238
+ for (const attachment of attachments) {
239
+ const { arg, bytes } = inlineFileArg(flag, attachment);
240
+ attachmentWire += arg.contents.length;
241
+ decodedTotal += bytes;
242
+ if (siblingWire + attachmentWire > MAX_REQUEST_PAYLOAD_WIRE_BYTES) {
243
+ // Report in the units the caller supplied — decoded file bytes — and say
244
+ // so explicitly when the attachments would have fit on their own, because
245
+ // then it is the body that tipped the balance and shrinking the files is
246
+ // the wrong response.
247
+ const blame = attachmentWire <= MAX_REQUEST_PAYLOAD_WIRE_BYTES
248
+ ? ` These attachments would fit on their own; the rest of the message (its body, mostly) `
249
+ + `spends ${siblingWire} bytes of the same budget.`
250
+ : '';
251
+ throw new Error(
252
+ `This message is too large to send: ${decodedTotal} bytes of attachments `
253
+ + `(${attachmentWire} bytes once base64-encoded for transit) exceed the `
254
+ + `${MAX_REQUEST_PAYLOAD_WIRE_BYTES}-byte request limit.${blame} The ceiling for attachments `
255
+ + `alone is ${MAX_INLINE_ATTACHMENT_TOTAL_BYTES} bytes `
256
+ + `(${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)}); a long body lowers it. Send fewer or `
257
+ + 'smaller files per message, shorten the body, or upload the large files to Drive and link them.',
258
+ );
259
+ }
260
+ args.push(arg);
261
+ }
262
+ return args;
263
+ }
@@ -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,
@@ -27,6 +29,20 @@ export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
27
29
  export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
28
30
  export { useRemoteGogRunner } from './remote-runner.js';
29
31
  export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
32
+ // Caller-supplied attachment bytes — the only outbound attachment path that
33
+ // works when the caller and gog share no filesystem (hosted connector, or any
34
+ // GOG_RUNNER_URL backend). See src/attachments.ts.
35
+ export {
36
+ attachInlineParam,
37
+ inlineAttachmentSchema,
38
+ inlineAttachmentArgs,
39
+ inlineFileArg,
40
+ INLINE_ATTACHMENT_LIMITS_TEXT,
41
+ MAX_INLINE_ATTACHMENT_BYTES,
42
+ MAX_INLINE_ATTACHMENT_TOTAL_BYTES,
43
+ MAX_REQUEST_PAYLOAD_WIRE_BYTES,
44
+ } from './attachments.js';
45
+ export type { InlineAttachmentInput } from './attachments.js';
30
46
  export {
31
47
  PAYLOAD_INLINE_MAX,
32
48
  payloadArg,