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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +122 -13
- package/dist/lib.js +130 -13
- package/manifest.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/attachments.ts +263 -0
- package/src/lib.ts +14 -0
- package/src/runner.ts +169 -14
- package/src/tools/gmail.ts +17 -3
- package/src/worker.ts +1 -1
- package/tests/attachments.test.ts +227 -0
- package/tests/runner-file-args-failure.test.ts +48 -3
- package/tests/runner.test.ts +126 -0
- package/tests/tools/gmail.test.ts +89 -0
|
@@ -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
|
-
/**
|
|
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;
|
|
@@ -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/tools/gmail.ts
CHANGED
|
@@ -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:
|
|
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('
|
|
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.
|
|
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.
|