gogcli-mcp 2.24.0 → 2.25.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.
@@ -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
+ }
package/src/lib.ts CHANGED
@@ -27,6 +27,20 @@ export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
27
27
  export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
28
28
  export { useRemoteGogRunner } from './remote-runner.js';
29
29
  export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
30
+ // Caller-supplied attachment bytes — the only outbound attachment path that
31
+ // works when the caller and gog share no filesystem (hosted connector, or any
32
+ // GOG_RUNNER_URL backend). See src/attachments.ts.
33
+ export {
34
+ attachInlineParam,
35
+ inlineAttachmentSchema,
36
+ inlineAttachmentArgs,
37
+ inlineFileArg,
38
+ INLINE_ATTACHMENT_LIMITS_TEXT,
39
+ MAX_INLINE_ATTACHMENT_BYTES,
40
+ MAX_INLINE_ATTACHMENT_TOTAL_BYTES,
41
+ MAX_REQUEST_PAYLOAD_WIRE_BYTES,
42
+ } from './attachments.js';
43
+ export type { InlineAttachmentInput } from './attachments.js';
30
44
  export {
31
45
  PAYLOAD_INLINE_MAX,
32
46
  payloadArg,
package/src/runner.ts CHANGED
@@ -18,12 +18,43 @@ export type Spawner = (
18
18
  export interface GogFileArg {
19
19
  /** Discriminant separating this from a plain argv string. */
20
20
  kind: 'file';
21
- /** Flag NAME without leading dashes, e.g. 'body-html-file'. */
21
+ /**
22
+ * Flag NAME without leading dashes, e.g. 'body-html-file'. The materialized
23
+ * path is passed as `--<flag>=<path>`, EXCEPT when `positional` is set, where
24
+ * this names the temp file's parent directory and nothing else.
25
+ */
22
26
  flag: string;
23
- /** The large payload, verbatim. */
27
+ /**
28
+ * The payload. Text verbatim when `encoding` is 'utf8' (the default); the
29
+ * base64 spelling of the bytes when it is 'base64'.
30
+ */
24
31
  contents: string;
25
32
  /** Temp-file extension without the dot, e.g. 'html'. Defaults to 'txt'. */
26
33
  ext?: string;
34
+ /**
35
+ * How to interpret `contents` when writing it. 'utf8' (default) preserves the
36
+ * existing text-payload behaviour exactly; 'base64' decodes first, which is
37
+ * what lets a caller hand over a PNG or a PDF without a shared filesystem.
38
+ */
39
+ encoding?: 'utf8' | 'base64';
40
+ /**
41
+ * Exact basename for the temp file, overriding the `<flag>.<ext>` default.
42
+ *
43
+ * Load-bearing for attachments: gog reads the MIME part's filename off the
44
+ * path it is given, so a file materialized as `attach.txt` would arrive in the
45
+ * recipient's mailbox named `attach.txt` no matter what the caller called it.
46
+ * Callers MUST pass an already-sanitized single path segment.
47
+ */
48
+ filename?: string;
49
+ /**
50
+ * Emit the materialized path as a BARE argv element instead of `--flag=path`.
51
+ *
52
+ * For subcommands taking the file as a positional argument — `gog drive
53
+ * upload <localPath>` is the only one today. Argument ORDER is preserved by
54
+ * every executor, so a positional file arg lands exactly where it sat in the
55
+ * caller's array.
56
+ */
57
+ positional?: boolean;
27
58
  }
28
59
 
29
60
  export type GogArg = string | GogFileArg;
@@ -157,6 +188,25 @@ export interface RunOptions {
157
188
  // carries no token, so stripping only real token shapes keeps it intact while
158
189
  // still catching any token that unexpectedly appears.
159
190
  redactMode?: 'full' | 'tokens';
191
+ // JSON string fields whose values are OPAQUE binary payloads this wrapper
192
+ // asked for by name — `contentBase64` from `gog gmail attachment --inline`
193
+ // being the only one today. Their values are lifted out before redaction runs
194
+ // and put back verbatim afterwards.
195
+ //
196
+ // Redaction exists to catch a credential that leaked into PROSE. A base64
197
+ // blob is not prose: it is uniformly-distributed bytes over a 64-character
198
+ // alphabet, so given enough of them it will eventually contain the literal
199
+ // spelling of any short secret shape by chance alone — `1//` at ~30% per
200
+ // attachment (see TOKEN_LEFT_BOUNDARY), and `AIza…` at ~0.2% even after that
201
+ // anchor lands. Boundary-anchoring the patterns fixes the common case;
202
+ // exempting the field fixes the CLASS, and keeps a future pattern added to
203
+ // mcp-utils from silently re-breaking attachments.
204
+ //
205
+ // Deliberately narrow in three ways: it is opt-in per call, only the named
206
+ // key is exempt, and only a value that is ENTIRELY base64 alphabet qualifies
207
+ // (see OPAQUE_FIELD_VALUE) — so a field carrying real prose, which is where a
208
+ // real leaked token would live, still gets redacted normally.
209
+ opaqueFields?: readonly string[];
160
210
  }
161
211
 
162
212
  const TIMEOUT_MS = 30_000;
@@ -197,6 +247,43 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
197
247
  return result;
198
248
  }
199
249
 
250
+ // The LEFT boundary every Google token shape below is anchored on, and the
251
+ // reason this file has a regression test named after a PNG.
252
+ //
253
+ // `1//` is three characters drawn entirely from the standard base64 alphabet,
254
+ // so the unanchored pattern `1\/\/[A-Za-z0-9._-]+` matches inside ANY base64
255
+ // blob that happens to contain that run — and then eats forward to the next
256
+ // `+` or `/`, deleting a slab out of the middle of the payload. `gog gmail
257
+ // attachment --inline` returns the attachment bytes as base64 in its JSON, that
258
+ // JSON goes through `run()`, and `run()` redacts. The result was a mangled
259
+ // `contentBase64` and an MCP protocol error at the client ("Invalid Base64
260
+ // string") on roughly a THIRD of all attachments — measured, not estimated: a
261
+ // 72 KiB file is ~97k base64 chars and the expected number of `1//` runs is
262
+ // n/64³ ≈ 0.37, i.e. P(corrupt) ≈ 30%.
263
+ //
264
+ // That coin-flip is what made the bug look like it was about FILENAMES: it
265
+ // correlates with nothing a reader can see, so two attachments in one thread
266
+ // differing only in name would land on opposite sides of it. It is content, not
267
+ // name — the runner has always spawned with an argv array and never a shell, so
268
+ // spaces in a filename were never able to split anything.
269
+ //
270
+ // A real token never appears WELDED to base64 text: it is delimited by a quote,
271
+ // whitespace, `=`, `:`, `&`, a bracket, or the start of the string. So requiring
272
+ // a non-base64 character (or nothing) to its left keeps every genuine detection
273
+ // and drops the mid-blob false positives, which by construction are always
274
+ // preceded by another base64 character.
275
+ //
276
+ // The class is EXACTLY the standard base64 alphabet, and no wider. Every
277
+ // character omitted from it is a delimiter a real token is found after, so each
278
+ // one added would silently cost a detection: `=` in particular would stop
279
+ // `refresh_token=1//0e…` and `access_token=ya29.…` — the form-encoded spelling,
280
+ // which the shared redactor's query-param rule does not catch without a
281
+ // preceding `?`/`&` — from being redacted at all. `=` is also unnecessary here,
282
+ // since base64 padding is terminal and can never precede a mid-blob `1//`.
283
+ // Likewise `.`, `_` and `-`: none occurs in standard base64, and `1//` cannot
284
+ // occur in base64url (which has no `/`), so neither alphabet needs them.
285
+ const TOKEN_LEFT_BOUNDARY = '(?<![A-Za-z0-9+/])';
286
+
200
287
  // Redact bearer/refresh-token patterns from error text before surfacing
201
288
  // it back to the MCP client. If gog ever emits a token in stderr (e.g.
202
289
  // from a verbose log mode), this prevents it from leaking to the model.
@@ -204,8 +291,8 @@ function sanitizedEnv(): NodeJS.ProcessEnv {
204
291
  // cookies, well-known key shapes (incl. Google AIza… API keys), and secret
205
292
  // query params — but not Google's OAuth2 token shapes, so those stay here.
206
293
  const GOOGLE_TOKEN_PATTERNS: RegExp[] = [
207
- /ya29\.[A-Za-z0-9._\-]+/g, // OAuth2 access tokens
208
- /1\/\/[A-Za-z0-9._\-]+/g, // OAuth2 refresh tokens
294
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}ya29\\.[A-Za-z0-9._\\-]+`, 'g'), // OAuth2 access tokens
295
+ new RegExp(`${TOKEN_LEFT_BOUNDARY}1//[A-Za-z0-9._\\-]+`, 'g'), // OAuth2 refresh tokens
209
296
  ];
210
297
  // Strip only Google's OAuth2 token shapes. Precise enough to leave an OAuth
211
298
  // consent URL (client_id, scope names, state, code_challenge) untouched.
@@ -220,6 +307,55 @@ export function redactSecrets(text: string): string {
220
307
  return redactGoogleTokens(redactSharedSecrets(text));
221
308
  }
222
309
 
310
+ // A JSON string value that is ENTIRELY standard/URL-safe base64 (plus padding),
311
+ // and long enough to be a payload rather than a flag. Anything else — a path, a
312
+ // MIME type, a sentence, an OAuth token sitting in prose — fails this and is
313
+ // redacted normally, which is what keeps the exemption from becoming a hole.
314
+ const OPAQUE_FIELD_VALUE = '[A-Za-z0-9+/_-]{16,}={0,2}';
315
+
316
+ // Placeholder standing in for a lifted value while redaction runs.
317
+ //
318
+ // NUL-delimited because NUL cannot occur in gog's output: stdout is decoded as
319
+ // UTF-8 text and JSON escapes it as a backslash-u escape, so the placeholder can never
320
+ // collide with real content the way a printable sentinel could. The body
321
+ // contains no character any redaction pattern keys on, and the index keeps each
322
+ // one unique so two blobs can never be swapped on restore.
323
+ const opaquePlaceholder = (i: number): string => `\u0000gogOpaque${i}\u0000`;
324
+
325
+ /**
326
+ * Redact `text` while leaving the values of `fields` untouched.
327
+ *
328
+ * Lift each `"field":"<base64>"` value out to a placeholder, redact what
329
+ * remains, then put the values back. Splicing rather than parsing keeps this on
330
+ * the raw string: `run()` returns text, gog's output is not always JSON, and a
331
+ * parse/re-serialize round trip would rewrite key order and number formatting
332
+ * in output the caller may be matching on.
333
+ */
334
+ export function redactPreservingOpaqueFields(
335
+ text: string,
336
+ fields: readonly string[],
337
+ redact: (input: string) => string,
338
+ ): string {
339
+ const lifted: string[] = [];
340
+ let staged = text;
341
+ for (const field of fields) {
342
+ // The key is escaped because it reaches a RegExp; the value class is fixed
343
+ // above, so a base64 payload can never terminate its own string early.
344
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
345
+ const re = new RegExp(`("${escaped}"\\s*:\\s*")(${OPAQUE_FIELD_VALUE})(")`, 'g');
346
+ staged = staged.replace(re, (_m, open: string, value: string, close: string) => {
347
+ lifted.push(value);
348
+ return `${open}${opaquePlaceholder(lifted.length - 1)}${close}`;
349
+ });
350
+ }
351
+ if (lifted.length === 0) return redact(text);
352
+ let redacted = redact(staged);
353
+ lifted.forEach((value, i) => {
354
+ redacted = redacted.split(opaquePlaceholder(i)).join(value);
355
+ });
356
+ return redacted;
357
+ }
358
+
223
359
  // MCP desktop clients often spawn servers with a stripped PATH that excludes
224
360
  // Homebrew, user-local, and Go's default install dirs — so even when gog is
225
361
  // installed, the spawned server can't find it. Augment the child's PATH with
@@ -266,7 +402,7 @@ async function spawnWithTempFiles(
266
402
  args: GogArg[],
267
403
  opts: { timeout?: number; interactive?: boolean; spawner?: Spawner; binary?: boolean },
268
404
  ): Promise<string> {
269
- const { mkdtemp, writeFile, rm } = await import('node:fs/promises');
405
+ const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
270
406
  const { tmpdir } = await import('node:os');
271
407
 
272
408
  // mkdtemp creates the directory with mode 0700 (owner-only) on POSIX, so the
@@ -275,17 +411,30 @@ async function spawnWithTempFiles(
275
411
  const dir = await mkdtemp(join(tmpdir(), 'gogcli-mcp-'));
276
412
  try {
277
413
  const argv: string[] = [];
414
+ let seq = 0;
278
415
  for (const arg of args) {
279
416
  if (!isGogFileArg(arg)) {
280
417
  argv.push(arg);
281
418
  continue;
282
419
  }
283
- // Name the file after the flag: one command can carry two payloads
284
- // (e.g. --body-file and --signature-file, both .txt), and a fixed
285
- // basename would have the second silently clobber the first.
286
- const path = join(dir, `${arg.flag}.${arg.ext ?? 'txt'}`);
287
- await writeFile(path, arg.contents, { encoding: 'utf8', mode: 0o600 });
288
- argv.push(`--${arg.flag}=${path}`);
420
+ // Each payload gets its own numbered SUBDIRECTORY, so the basename is free
421
+ // to be whatever the caller needs without any risk of one payload
422
+ // clobbering another. That matters twice over now: `--attach` is
423
+ // repeatable, so a single send can carry several files whose real names
424
+ // are chosen by the caller and may well collide (two `chart.png`s from
425
+ // different folders), and an attachment's basename is what the recipient
426
+ // sees, so it cannot be uniquified by mangling it.
427
+ const sub = join(dir, String(seq));
428
+ seq += 1;
429
+ await mkdir(sub, { recursive: true, mode: 0o700 });
430
+ const path = join(sub, arg.filename ?? `${arg.flag}.${arg.ext ?? 'txt'}`);
431
+ // 'base64' decodes to the real bytes; 'utf8' writes the string as-is,
432
+ // which is the pre-existing behaviour for every text payload.
433
+ const data = arg.encoding === 'base64'
434
+ ? Buffer.from(arg.contents, 'base64')
435
+ : Buffer.from(arg.contents, 'utf8');
436
+ await writeFile(path, data, { mode: 0o600 });
437
+ argv.push(arg.positional ? path : `--${arg.flag}=${path}`);
289
438
  }
290
439
  return await spawnGog(argv, opts);
291
440
  } finally {
@@ -406,8 +555,14 @@ function assembleArgs(
406
555
  }
407
556
 
408
557
  export async function run(args: GogArg[], options: RunOptions = {}): Promise<string> {
409
- const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full' } = options;
410
- const redact = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
558
+ const { account, spawner, interactive = false, timeout, readonly = false, redactMode = 'full', opaqueFields } = options;
559
+ const base = redactMode === 'tokens' ? redactGoogleTokens : redactSecrets;
560
+ // Only OUTPUT carries opaque payloads. An error message is prose by
561
+ // definition, so it always takes the plain redactor — exempting a field there
562
+ // would be exempting exactly the text a leaked token would appear in.
563
+ const redact = opaqueFields?.length
564
+ ? (text: string): string => redactPreservingOpaqueFields(text, opaqueFields, base)
565
+ : base;
411
566
 
412
567
  const fullArgs = assembleArgs(args, { account, interactive, readonly });
413
568
 
@@ -433,7 +588,7 @@ export async function run(args: GogArg[], options: RunOptions = {}): Promise<str
433
588
  // A thrown non-Error would make `.message` undefined and redact() blow up
434
589
  // with a TypeError, masking the real failure. Same instanceof guard the
435
590
  // codebase already uses in errorText() (tools/utils.ts).
436
- const message = redact(err instanceof Error ? err.message : String(err));
591
+ const message = base(err instanceof Error ? err.message : String(err));
437
592
  // Redaction must not cost the error its TYPE. `RunnerTransportError` is the
438
593
  // structural claim "this failure was ours, not Google's"; flattening it to a
439
594
  // bare Error here would put diagnose() straight back to guessing from prose,
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { accountParam, runOrDiagnose, registerRunTool, payloadArg, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
4
4
  import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
5
5
  import type { GogArg } from '../runner.js';
6
+ import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
6
7
 
7
8
  export function registerGmailTools(server: McpServer): void {
8
9
  server.registerTool('gog_gmail_search', {
@@ -66,7 +67,13 @@ export function registerGmailTools(server: McpServer): void {
66
67
  });
67
68
 
68
69
  server.registerTool('gog_gmail_send', {
69
- description: 'Send an email. When attach is used, the JSON result echoes the attached filenames and byte sizes — check it to confirm the files were found and embedded.',
70
+ description:
71
+ 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
72
+ + '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
73
+ + 'the same machine gog runs on — on the hosted connector and any remote deployment there is no '
74
+ + 'shared filesystem, so no path you can name resolves there and `attach` will fail with '
75
+ + '"no such file or directory". When either is used, the JSON result echoes the attached filenames '
76
+ + 'and byte sizes — check it to confirm the files were embedded.',
70
77
  annotations: { destructiveHint: true },
71
78
  inputSchema: {
72
79
  to: z.string().describe('Recipient(s), comma-separated'),
@@ -76,10 +83,11 @@ export function registerGmailTools(server: McpServer): void {
76
83
  bcc: z.string().optional().describe('BCC recipients, comma-separated'),
77
84
  replyToMessageId: z.string().optional().describe('Message ID to reply to'),
78
85
  threadId: z.string().optional().describe('Thread ID to reply within'),
79
- attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Each file is read on the gog server (not this client), base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment. Keep the total under Gmail\'s ~35 MB inline-upload limit.'),
86
+ attach: z.array(z.string()).optional().describe('File paths to attach (repeatable), resolved ON THE GOG SERVER\'s filesystem — NOT this client\'s. Only usable when gog runs on the same machine you do (local stdio); on the hosted connector or any GOG_RUNNER_URL backend these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Each file is read on the server, base64-encoded with a MIME type inferred from its extension, and added as a multipart attachment.'),
87
+ attachInline: attachInlineParam,
80
88
  account: accountParam,
81
89
  },
82
- }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, account }) => {
90
+ }, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
83
91
  // A long body cannot ride in argv: the hosted runner caps a single arg and
84
92
  // Linux caps MAX_ARG_STRLEN at 128 KiB. payloadArg swaps it for --body-file
85
93
  // past the shared threshold; the executor materializes the temp file.
@@ -89,6 +97,12 @@ export function registerGmailTools(server: McpServer): void {
89
97
  if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
90
98
  if (threadId) args.push(`--thread-id=${threadId}`);
91
99
  if (attach) for (const path of attach) args.push(`--attach=${path}`);
100
+ // Same repeatable --attach flag; the executor materializes each payload to a
101
+ // temp file beside gog and substitutes its path. `args` is passed so the
102
+ // size check sees the whole request — chiefly the body, which is itself a
103
+ // file arg once it passes payloadArg's threshold and spends the same budget.
104
+ const inline = inlineAttachmentArgs('attach', attachInline, args);
105
+ args.push(...inline);
92
106
  return runOrDiagnose(args, { account });
93
107
  });
94
108
 
package/src/worker.ts CHANGED
@@ -38,7 +38,7 @@ import { gogAuth, CONNECTOR_INSTRUCTIONS, type GogProps } from './connector-auth
38
38
  // connector with all ~360 tools at once. Add whichever paths you want as separate
39
39
  // connectors in claude.ai (each authorizes with the same connector key).
40
40
 
41
- const VERSION = '2.24.0'; // x-release-please-version
41
+ const VERSION = '2.25.0'; // x-release-please-version
42
42
 
43
43
  // Build an McpAgent subclass whose init() registers `registrars` onto its server,
44
44
  // each handler wrapped in the ALS scope carrying the per-session Fly executor.