gogcli-mcp-gmail 2.7.1 → 2.18.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/README.md +1 -1
- package/SKILL.md +1 -1
- package/dist/index.js +20203 -19642
- package/manifest.json +23 -3
- package/package.json +3 -2
- package/src/index.ts +7 -9
- package/src/tools/gmail-extra.ts +517 -49
- package/tests/tools/gmail-extra.test.ts +735 -135
- package/tsconfig.json +1 -1
package/src/tools/gmail-extra.ts
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { rawTextResult, textResult, errorResult } from '@chrischall/mcp-utils';
|
|
5
|
+
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor } from '../../../gogcli-mcp/src/lib.js';
|
|
6
|
+
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
|
+
|
|
8
|
+
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
9
|
+
// create` errors with "use only one of --body-html or --body-html-file", and
|
|
10
|
+
// `gmail forward` does the same for --note (misreporting it as --body). Catch
|
|
11
|
+
// the conflict here so the caller gets a message naming the TOOL params it
|
|
12
|
+
// actually passed, instead of a gog error naming flags it never saw.
|
|
13
|
+
function assertNotBoth(
|
|
14
|
+
inlineParam: string,
|
|
15
|
+
fileParam: string,
|
|
16
|
+
inlineValue: string | undefined,
|
|
17
|
+
fileValue: string | undefined,
|
|
18
|
+
): void {
|
|
19
|
+
if (inlineValue !== undefined && fileValue !== undefined) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`${inlineParam} and ${fileParam} are mutually exclusive — gog accepts only one of them. ` +
|
|
22
|
+
`Pass ${inlineParam} with the content itself (it is written to a temp file automatically when large), ` +
|
|
23
|
+
`or ${fileParam} with a path that already exists on the gog server.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pull the text out of a single-text-block tool result; undefined for any
|
|
29
|
+
// other shape (an error result is still a text block, so it parses below).
|
|
30
|
+
function resultText(result: CallToolResult): string | undefined {
|
|
31
|
+
const first = result.content[0];
|
|
32
|
+
return first?.type === 'text' ? first.text : undefined;
|
|
33
|
+
}
|
|
4
34
|
|
|
5
35
|
type GmailHeader = { name?: string; value?: string };
|
|
6
36
|
type GmailMessage = {
|
|
@@ -41,23 +71,248 @@ function summarizeMessage(m: GmailMessage): Record<string, unknown> {
|
|
|
41
71
|
// message-limit flag, so this is done by post-processing its output. Any
|
|
42
72
|
// non-JSON output (an error, an unexpected shape) is passed through untouched.
|
|
43
73
|
function trimThread(
|
|
44
|
-
result:
|
|
74
|
+
result: CallToolResult,
|
|
45
75
|
latestN: number | undefined,
|
|
46
76
|
snippetsOnly: boolean | undefined,
|
|
47
|
-
):
|
|
77
|
+
): CallToolResult {
|
|
48
78
|
try {
|
|
49
|
-
const parsed = JSON.parse(result
|
|
79
|
+
const parsed = JSON.parse(resultText(result) ?? '') as { thread?: { messages?: unknown[] } };
|
|
50
80
|
const messages = parsed.thread?.messages;
|
|
51
81
|
if (!Array.isArray(messages)) return result;
|
|
52
82
|
let trimmed: unknown[] = messages;
|
|
53
83
|
if (latestN !== undefined) trimmed = trimmed.slice(-latestN);
|
|
54
84
|
if (snippetsOnly) trimmed = trimmed.map((m) => summarizeMessage(m as GmailMessage));
|
|
55
|
-
return
|
|
85
|
+
return rawTextResult(JSON.stringify({ ...parsed, thread: { ...parsed.thread, messages: trimmed } }));
|
|
56
86
|
} catch {
|
|
57
87
|
return result;
|
|
58
88
|
}
|
|
59
89
|
}
|
|
60
90
|
|
|
91
|
+
// `gog gmail attachment --inline --json` emits these fields (NO filename, NO MIME
|
|
92
|
+
// type): base64 content when the attachment is within gog's 3 MiB inline cap,
|
|
93
|
+
// otherwise just the on-disk path plus a `reason` explaining the size fallback.
|
|
94
|
+
type InlineAttachment = {
|
|
95
|
+
path?: string;
|
|
96
|
+
bytes?: number;
|
|
97
|
+
cached?: boolean;
|
|
98
|
+
contentBase64?: string;
|
|
99
|
+
reason?: string;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// One entry from `gog gmail get --json` `.attachments[]`: the message part
|
|
103
|
+
// metadata, which carries the TRUE filename and MIME type that the download
|
|
104
|
+
// endpoint itself does not report. `size` is the stable key we match on —
|
|
105
|
+
// Gmail's `attachmentId` is NOT stable across calls (see resolveBySize).
|
|
106
|
+
type AttachmentMeta = { filename?: string; mimeType?: string; attachmentId?: string; size?: number };
|
|
107
|
+
|
|
108
|
+
// MIME type by file extension — the download endpoint reports none, and the
|
|
109
|
+
// client needs it to render an inline image or label an embedded resource. Part
|
|
110
|
+
// metadata (authoritative) and a magic-byte sniff (below) backstop this.
|
|
111
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
112
|
+
pdf: 'application/pdf',
|
|
113
|
+
png: 'image/png',
|
|
114
|
+
jpg: 'image/jpeg',
|
|
115
|
+
jpeg: 'image/jpeg',
|
|
116
|
+
gif: 'image/gif',
|
|
117
|
+
webp: 'image/webp',
|
|
118
|
+
svg: 'image/svg+xml',
|
|
119
|
+
bmp: 'image/bmp',
|
|
120
|
+
tiff: 'image/tiff',
|
|
121
|
+
heic: 'image/heic',
|
|
122
|
+
txt: 'text/plain',
|
|
123
|
+
csv: 'text/csv',
|
|
124
|
+
md: 'text/markdown',
|
|
125
|
+
json: 'application/json',
|
|
126
|
+
xml: 'application/xml',
|
|
127
|
+
html: 'text/html',
|
|
128
|
+
htm: 'text/html',
|
|
129
|
+
ics: 'text/calendar',
|
|
130
|
+
zip: 'application/zip',
|
|
131
|
+
doc: 'application/msword',
|
|
132
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
133
|
+
xls: 'application/vnd.ms-excel',
|
|
134
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
135
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
136
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Reverse map for naming a part that carried no filename: MIME type → a canonical
|
|
140
|
+
// extension. Deliberate: a known type must never be saved as `*.bin`. Later
|
|
141
|
+
// entries win, so image/jpeg resolves to `jpeg` and text/html to `htm` — both fine.
|
|
142
|
+
const EXT_BY_MIME: Record<string, string> = Object.fromEntries(
|
|
143
|
+
Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext]),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Lowercased extension of a filename, or '' if it has none.
|
|
147
|
+
function extOf(name: string): string {
|
|
148
|
+
const dot = name.lastIndexOf('.');
|
|
149
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Make a caller- or part-supplied name safe as a SINGLE path segment: no
|
|
153
|
+
// directory separators or traversal (it is interpolated into an --out path gog
|
|
154
|
+
// creates server-side), no control chars, bounded length.
|
|
155
|
+
function sanitizeFilename(name: string): string {
|
|
156
|
+
const base = name
|
|
157
|
+
.replace(/[/\\]+/g, '_')
|
|
158
|
+
// eslint-disable-next-line no-control-regex
|
|
159
|
+
.replace(/[\x00-\x1f]/g, '')
|
|
160
|
+
.replace(/^\.+/, '')
|
|
161
|
+
.trim()
|
|
162
|
+
.slice(0, 200);
|
|
163
|
+
return base || 'attachment';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Leading magic bytes → MIME type, for the common binary attachments.
|
|
167
|
+
const MAGIC_SIGNATURES: ReadonlyArray<readonly [string, string]> = [
|
|
168
|
+
['%PDF', 'application/pdf'],
|
|
169
|
+
['\x89PNG', 'image/png'],
|
|
170
|
+
['\xFF\xD8\xFF', 'image/jpeg'],
|
|
171
|
+
['GIF8', 'image/gif'],
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
// Sniff a MIME type from the leading bytes of standard base64; returns undefined
|
|
175
|
+
// for anything unrecognised. gog emits standard base64, and any 4-aligned prefix
|
|
176
|
+
// of a valid base64 string is itself valid, so atob never throws here.
|
|
177
|
+
function sniffMime(base64: string): string | undefined {
|
|
178
|
+
const head = atob(base64.slice(0, 16)); // 4-aligned slice; decodes to ~12 bytes
|
|
179
|
+
for (const [signature, mimeType] of MAGIC_SIGNATURES) {
|
|
180
|
+
if (head.startsWith(signature)) return mimeType;
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resolve the real filename + MIME type from the message part metadata
|
|
186
|
+
// (`gog gmail get` `.attachments[]`), which the download endpoint doesn't return.
|
|
187
|
+
// A lightweight metadata read — no body bytes.
|
|
188
|
+
//
|
|
189
|
+
// The match is by SIZE, not attachmentId: Gmail's attachmentId is not stable
|
|
190
|
+
// across API calls (a fresh `get` re-issues a different id for the same part, as
|
|
191
|
+
// verified live), so the caller's id can't be matched against a fresh listing.
|
|
192
|
+
// The downloaded byte count is stable and, in practice, unique per message — so
|
|
193
|
+
// it identifies the part. An ambiguous (repeated) size yields undefined, and the
|
|
194
|
+
// caller falls back to a magic-byte sniff + a MIME-derived name.
|
|
195
|
+
async function resolveBySize(
|
|
196
|
+
messageId: string,
|
|
197
|
+
sizeBytes: number | undefined,
|
|
198
|
+
account: string | undefined,
|
|
199
|
+
): Promise<AttachmentMeta | undefined> {
|
|
200
|
+
if (sizeBytes === undefined) return undefined;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(await run(['gmail', 'get', messageId], { account })) as {
|
|
203
|
+
attachments?: AttachmentMeta[];
|
|
204
|
+
};
|
|
205
|
+
const matches = (parsed.attachments ?? []).filter((a) => a.size === sizeBytes);
|
|
206
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// A writable, ephemeral server-side output path. gog MkdirAll's the tree, and
|
|
213
|
+
// /tmp is writable on both the local host and the Fly backend AND is cleared when
|
|
214
|
+
// the machine stops — unlike gog's default (the gogcli config dir), which on the
|
|
215
|
+
// Fly volume would accumulate downloaded attachments indefinitely.
|
|
216
|
+
function defaultOutPath(messageId: string, filename: string): string {
|
|
217
|
+
return `/tmp/gog-attachments/${messageId}/${filename}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Strip the command echo and any message/attachment ids from a gog failure before
|
|
221
|
+
// it reaches the caller. On the Fly backend gog runs under execFile, whose error
|
|
222
|
+
// message is `Command failed: gog gmail attachment <msg> <token> ...\n<stderr>` —
|
|
223
|
+
// leaking the full command line (opaque attachment token included) and a raw shell
|
|
224
|
+
// error such as `mkdir /home/claude: permission denied`. Keep only the stderr
|
|
225
|
+
// tail, with the ids redacted.
|
|
226
|
+
function sanitizeAttachmentError(err: unknown, messageId: string, attachmentId: string): string {
|
|
227
|
+
let msg = err instanceof Error ? err.message : String(err);
|
|
228
|
+
msg = msg.replace(/^Command failed:.*(\n|$)/, ''); // drop the command echo line
|
|
229
|
+
msg = msg.split(attachmentId).join('<attachment>').split(messageId).join('<message>');
|
|
230
|
+
return msg.trim() || 'the download failed on the server';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The attachment bytes as a native image block (so clients render them). A
|
|
234
|
+
// leading text block summarises the attachment for the model.
|
|
235
|
+
function inlineImageResult(summary: string, base64: string, mimeType: string): CallToolResult {
|
|
236
|
+
return { content: [{ type: 'text', text: summary }, { type: 'image', data: base64, mimeType }] };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The attachment bytes as an MCP embedded-resource blob. Only some hosts render
|
|
240
|
+
// or accept non-image resources (claude.ai currently rejects application/pdf),
|
|
241
|
+
// so this is reserved for the explicit deliver="inline" escape hatch — deliver
|
|
242
|
+
// "auto" never routes bytes the host would silently drop through here.
|
|
243
|
+
function inlineResourceResult(
|
|
244
|
+
messageId: string,
|
|
245
|
+
filename: string,
|
|
246
|
+
summary: string,
|
|
247
|
+
base64: string,
|
|
248
|
+
mimeType: string,
|
|
249
|
+
): CallToolResult {
|
|
250
|
+
return {
|
|
251
|
+
content: [
|
|
252
|
+
{ type: 'text', text: summary },
|
|
253
|
+
{
|
|
254
|
+
type: 'resource',
|
|
255
|
+
resource: { uri: `gmail-attachment://${messageId}/${filename}`, mimeType, blob: base64 },
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// A file-path delivery: the bytes were written server-side and the caller reads
|
|
262
|
+
// them from `path`. Used on the local (stdio) transport, where the caller shares
|
|
263
|
+
// the filesystem — the remote connector uses Drive instead.
|
|
264
|
+
function fileResult(
|
|
265
|
+
path: string,
|
|
266
|
+
fileName: string,
|
|
267
|
+
mimeType: string,
|
|
268
|
+
bytes: number | undefined,
|
|
269
|
+
): CallToolResult {
|
|
270
|
+
return textResult({
|
|
271
|
+
delivery: 'file',
|
|
272
|
+
path,
|
|
273
|
+
fileName,
|
|
274
|
+
mimeType,
|
|
275
|
+
bytes,
|
|
276
|
+
note: 'Saved on the server filesystem — read it from `path`.',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Prepend an advisory note (e.g. why a caller-supplied `out` was ignored) to a
|
|
281
|
+
// result's content, or return it unchanged when there is nothing to say.
|
|
282
|
+
function withNote(result: CallToolResult, notes: string[]): CallToolResult {
|
|
283
|
+
if (notes.length === 0) return result;
|
|
284
|
+
return { ...result, content: [{ type: 'text', text: notes.join(' ') }, ...result.content] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Upload the file gog wrote (server-side, on the same box that ran the download)
|
|
288
|
+
// to Google Drive and return its metadata + shareable link. This is how a large
|
|
289
|
+
// or non-renderable attachment — one the connector can't hand back inline —
|
|
290
|
+
// reaches the caller.
|
|
291
|
+
async function deliverViaDrive(
|
|
292
|
+
path: string,
|
|
293
|
+
name: string,
|
|
294
|
+
driveFolder: string | undefined,
|
|
295
|
+
account: string | undefined,
|
|
296
|
+
): Promise<CallToolResult> {
|
|
297
|
+
const args = ['drive', 'upload', path, '--json'];
|
|
298
|
+
if (driveFolder) args.push(`--parent=${driveFolder}`);
|
|
299
|
+
args.push(`--name=${name}`); // callers always resolve a filename first
|
|
300
|
+
// `gog drive upload --json` wraps the created file under a `file` key.
|
|
301
|
+
const parsed = JSON.parse(await run(args, { account })) as {
|
|
302
|
+
file?: { id?: string; name?: string; mimeType?: string; size?: number | string; webViewLink?: string };
|
|
303
|
+
};
|
|
304
|
+
const file = parsed.file ?? {};
|
|
305
|
+
return textResult({
|
|
306
|
+
deliveredVia: 'drive',
|
|
307
|
+
note: 'Attachment delivered via Google Drive; open or download it at webViewLink.',
|
|
308
|
+
id: file.id,
|
|
309
|
+
name: file.name,
|
|
310
|
+
mimeType: file.mimeType,
|
|
311
|
+
size: file.size,
|
|
312
|
+
webViewLink: file.webViewLink,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
61
316
|
export function registerExtraGmailTools(server: McpServer): void {
|
|
62
317
|
server.registerTool('gog_gmail_raw', {
|
|
63
318
|
description: 'Dump the raw Gmail API response as JSON (lossless; for scripting and LLM consumption).',
|
|
@@ -76,20 +331,126 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
76
331
|
});
|
|
77
332
|
|
|
78
333
|
server.registerTool('gog_gmail_attachment', {
|
|
79
|
-
description:
|
|
80
|
-
|
|
334
|
+
description:
|
|
335
|
+
'Download a Gmail attachment and deliver its contents so you can actually read them. The real filename ' +
|
|
336
|
+
'and MIME type are resolved from the message part metadata, so the saved file and response are named ' +
|
|
337
|
+
'correctly (e.g. Guest_Copy.pdf), never a generic *.bin. deliver="auto" (default) is transport-aware: ' +
|
|
338
|
+
'images always come back as a native image block; anything else is delivered by the channel that works ' +
|
|
339
|
+
'on your transport — a readable server-side file PATH on local (stdio) clients that share the filesystem, ' +
|
|
340
|
+
'or a Google Drive link on the remote connector (whose backend filesystem you can\'t read, and which ' +
|
|
341
|
+
'rejects inline PDF/binary blobs). deliver="inline" forces the bytes inline as an image or embedded ' +
|
|
342
|
+
'resource blob (use only if your client consumes resource blobs; errors if over gog\'s 3 MiB cap). ' +
|
|
343
|
+
'deliver="drive" always uploads to Drive; deliver="off" writes the file server-side and returns ' +
|
|
344
|
+
'{path, fileName, mimeType, bytes}. Drive delivery creates a file in your Drive (blocked when GOG_READONLY is set).',
|
|
81
345
|
inputSchema: {
|
|
82
346
|
messageId: z.string().describe('Gmail message ID'),
|
|
83
347
|
attachmentId: z.string().describe('Attachment ID (from the message payload)'),
|
|
84
|
-
|
|
85
|
-
|
|
348
|
+
deliver: z
|
|
349
|
+
.enum(['auto', 'inline', 'drive', 'off'])
|
|
350
|
+
.optional()
|
|
351
|
+
.describe('How to return the contents: auto (image inline; else a local file path or a Drive link, per transport), inline (force bytes as image/resource blob), drive (always a Drive link), or off (server-side download only). Default: auto.'),
|
|
352
|
+
out: z.string().optional().describe('Server-side path where gog writes the file. NOTE: this resolves on the CONNECTOR/gog server\'s filesystem, not your machine — on the remote connector it is ignored (you can\'t read it; you get a Drive link instead). Locally it is honored. Omit it to use an ephemeral temp path.'),
|
|
353
|
+
name: z.string().optional().describe('Filename override. Defaults to the attachment\'s real filename from the message metadata; pass this to skip that lookup or force a name.'),
|
|
354
|
+
driveFolder: z.string().optional().describe('Destination Google Drive folder ID for the uploaded copy (drive/auto delivery on the remote connector, or oversized attachments).'),
|
|
86
355
|
account: accountParam,
|
|
87
356
|
},
|
|
88
|
-
}, async ({ messageId, attachmentId, out, name, account }) => {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
357
|
+
}, async ({ messageId, attachmentId, deliver = 'auto', out, name, driveFolder, account }) => {
|
|
358
|
+
// On the remote connector, `run` forwards to the Fly backend and this store
|
|
359
|
+
// is set; on local stdio it is unset. It is the one signal that tells apart
|
|
360
|
+
// "the caller shares my filesystem" (stdio → deliver a path) from "the caller
|
|
361
|
+
// can't read my disk and rejects binary blobs" (connector → deliver a Drive
|
|
362
|
+
// link). It also decides whether a caller-supplied `out` is meaningful.
|
|
363
|
+
const remote = runExecutor.getStore() !== undefined;
|
|
364
|
+
try {
|
|
365
|
+
// 1. Start from the caller's `name` (the recommended path — the caller got
|
|
366
|
+
// the attachmentId from a listing that also carried the filename). The
|
|
367
|
+
// download endpoint reports neither name nor MIME; when `name` is absent
|
|
368
|
+
// we resolve them AFTER downloading, by matching the byte count against
|
|
369
|
+
// the part metadata (attachmentIds aren't stable enough to match on).
|
|
370
|
+
let filename = name ? sanitizeFilename(name) : undefined;
|
|
371
|
+
let mimeType: string | undefined = filename ? MIME_BY_EXT[extOf(filename)] : undefined;
|
|
372
|
+
|
|
373
|
+
// 2. Choose the server-side output path. A caller `out` only makes sense on
|
|
374
|
+
// the local transport; on the connector it resolves on the backend the
|
|
375
|
+
// caller can't read, so ignore it (with a note) and use a temp path. The
|
|
376
|
+
// on-disk basename is provisional when `name` is absent; the response
|
|
377
|
+
// still reports the resolved filename.
|
|
378
|
+
const notes: string[] = [];
|
|
379
|
+
let outPath = out;
|
|
380
|
+
if (out && remote) {
|
|
381
|
+
notes.push("`out` was ignored: it resolves on the connector's server filesystem, which you can't read.");
|
|
382
|
+
outPath = undefined;
|
|
383
|
+
}
|
|
384
|
+
if (!outPath) outPath = defaultOutPath(messageId, filename ?? 'attachment');
|
|
385
|
+
|
|
386
|
+
// 3. Download. --inline returns the bytes for the image/resource cases; skip
|
|
387
|
+
// it when we already know delivery is by path or Drive (don't ship base64
|
|
388
|
+
// only to discard it). When the MIME type is still unknown we must
|
|
389
|
+
// --inline to sniff it from the bytes.
|
|
390
|
+
let needInline = deliver === 'auto' || deliver === 'inline';
|
|
391
|
+
if (deliver === 'auto' && remote && mimeType && !mimeType.startsWith('image/')) {
|
|
392
|
+
needInline = false; // headed to Drive anyway
|
|
393
|
+
}
|
|
394
|
+
if (!mimeType && (deliver === 'auto' || deliver === 'inline')) {
|
|
395
|
+
needInline = true; // need the bytes to sniff the type
|
|
396
|
+
}
|
|
397
|
+
const args = ['gmail', 'attachment', messageId, attachmentId];
|
|
398
|
+
if (needInline) args.push('--inline');
|
|
399
|
+
args.push(`--out=${outPath}`, `--name=${filename ?? 'attachment'}`);
|
|
400
|
+
const info = JSON.parse(await run(args, { account })) as InlineAttachment;
|
|
401
|
+
const path = info.path ?? outPath;
|
|
402
|
+
|
|
403
|
+
// 4. Resolve the real filename/MIME when `name` wasn't supplied, by matching
|
|
404
|
+
// the downloaded byte count against the part metadata.
|
|
405
|
+
if (!filename) {
|
|
406
|
+
const meta = await resolveBySize(messageId, info.bytes, account);
|
|
407
|
+
if (meta?.filename) filename = sanitizeFilename(meta.filename);
|
|
408
|
+
if (!mimeType && meta?.mimeType) mimeType = meta.mimeType;
|
|
409
|
+
}
|
|
410
|
+
if (!mimeType && info.contentBase64) mimeType = sniffMime(info.contentBase64);
|
|
411
|
+
mimeType = mimeType ?? 'application/octet-stream';
|
|
412
|
+
// Still no filename → derive one from the MIME type; never leave it *.bin.
|
|
413
|
+
if (!filename) {
|
|
414
|
+
const ext = EXT_BY_MIME[mimeType];
|
|
415
|
+
filename = ext ? `attachment.${ext}` : 'attachment';
|
|
416
|
+
}
|
|
417
|
+
const isImage = mimeType.startsWith('image/');
|
|
418
|
+
const summary = `${filename} — ${info.bytes ?? '?'} bytes, ${mimeType}, returned inline.`;
|
|
419
|
+
|
|
420
|
+
// 5. Deliver by the requested channel. Every delivery is wrapped with
|
|
421
|
+
// `notes` so an ignored-`out` explanation is never silently dropped,
|
|
422
|
+
// whatever the mode or type.
|
|
423
|
+
if (deliver === 'off') {
|
|
424
|
+
return withNote(textResult({ delivery: 'file', path, cached: info.cached, bytes: info.bytes, fileName: filename, mimeType }), notes);
|
|
425
|
+
}
|
|
426
|
+
if (deliver === 'drive') {
|
|
427
|
+
return withNote(await deliverViaDrive(path, filename, driveFolder, account), notes);
|
|
428
|
+
}
|
|
429
|
+
if (deliver === 'inline') {
|
|
430
|
+
if (info.contentBase64) {
|
|
431
|
+
return withNote(isImage
|
|
432
|
+
? inlineImageResult(summary, info.contentBase64, mimeType)
|
|
433
|
+
: inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
|
|
434
|
+
}
|
|
435
|
+
return errorResult(
|
|
436
|
+
`Attachment is too large to return inline (${info.reason ?? "exceeds gog's 3 MiB inline limit"}). ` +
|
|
437
|
+
'Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.',
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
// deliver === 'auto': images render everywhere; everything else goes by the
|
|
441
|
+
// channel that works on this transport.
|
|
442
|
+
if (isImage && info.contentBase64) {
|
|
443
|
+
return withNote(inlineImageResult(summary, info.contentBase64, mimeType), notes);
|
|
444
|
+
}
|
|
445
|
+
if (remote) {
|
|
446
|
+
return withNote(await deliverViaDrive(path, filename, driveFolder, account), notes);
|
|
447
|
+
}
|
|
448
|
+
return withNote(fileResult(path, filename, mimeType, info.bytes), notes);
|
|
449
|
+
} catch (err) {
|
|
450
|
+
// Never surface gog's raw error (it echoes the full command line + attachment
|
|
451
|
+
// token on the backend); redact the ids and let diagnose classify the rest.
|
|
452
|
+
return diagnose(new Error(sanitizeAttachmentError(err, messageId, attachmentId)));
|
|
453
|
+
}
|
|
93
454
|
});
|
|
94
455
|
|
|
95
456
|
server.registerTool('gog_gmail_url', {
|
|
@@ -122,11 +483,12 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
122
483
|
return runOrDiagnose(args, { account });
|
|
123
484
|
});
|
|
124
485
|
|
|
125
|
-
const bulkActions: Array<{ tool: string; cmd: string; description: string }> = [
|
|
486
|
+
const bulkActions: Array<{ tool: string; cmd: string; description: string; supportsThread?: boolean }> = [
|
|
126
487
|
{
|
|
127
488
|
tool: 'gog_gmail_archive',
|
|
128
489
|
cmd: 'archive',
|
|
129
|
-
description: 'Archive messages (remove from inbox). Pass either messageIds or a Gmail search query.',
|
|
490
|
+
description: 'Archive messages (remove from inbox). Pass either messageIds or a Gmail search query. Set thread=true to treat the ids as THREAD ids and archive every message in each thread (the right mode for ids that came from thread search).',
|
|
491
|
+
supportsThread: true,
|
|
130
492
|
},
|
|
131
493
|
{
|
|
132
494
|
tool: 'gog_gmail_mark_read',
|
|
@@ -145,21 +507,29 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
145
507
|
},
|
|
146
508
|
];
|
|
147
509
|
|
|
148
|
-
for (const { tool, cmd, description } of bulkActions) {
|
|
510
|
+
for (const { tool, cmd, description, supportsThread } of bulkActions) {
|
|
511
|
+
const inputSchema: Record<string, z.ZodTypeAny> = {
|
|
512
|
+
messageIds: z.array(z.string()).optional().describe('Specific message IDs to act on'),
|
|
513
|
+
query: z.string().optional().describe('Gmail search query (alternative to messageIds; acts on all matching)'),
|
|
514
|
+
max: z.number().optional().describe('Max messages when using --query (default: 100)'),
|
|
515
|
+
account: accountParam,
|
|
516
|
+
};
|
|
517
|
+
if (supportsThread) {
|
|
518
|
+
inputSchema.thread = z.boolean().optional().describe('Treat messageIds as THREAD ids and act on every message in each thread');
|
|
519
|
+
}
|
|
149
520
|
server.registerTool(tool, {
|
|
150
521
|
description,
|
|
151
522
|
annotations: { destructiveHint: true },
|
|
152
|
-
inputSchema
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
max
|
|
156
|
-
|
|
157
|
-
},
|
|
158
|
-
}, async ({ messageIds, query, max, account }) => {
|
|
523
|
+
inputSchema,
|
|
524
|
+
}, async (rawArgs) => {
|
|
525
|
+
const { messageIds, query, max, thread, account } = rawArgs as {
|
|
526
|
+
messageIds?: string[]; query?: string; max?: number; thread?: boolean; account?: string;
|
|
527
|
+
};
|
|
159
528
|
const args = ['gmail', cmd];
|
|
160
529
|
if (messageIds) args.push(...messageIds);
|
|
161
530
|
if (query) args.push(`--query=${query}`);
|
|
162
531
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
532
|
+
if (supportsThread && thread) args.push('--thread');
|
|
163
533
|
return runOrDiagnose(args, { account });
|
|
164
534
|
});
|
|
165
535
|
}
|
|
@@ -181,14 +551,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
181
551
|
});
|
|
182
552
|
|
|
183
553
|
server.registerTool('gog_gmail_batch_delete', {
|
|
184
|
-
description: 'Permanently delete multiple messages (requires the broader Gmail scope). Use gog_gmail_trash for normal deletes.',
|
|
554
|
+
description: 'Permanently delete multiple messages (requires the broader Gmail scope; not reversible — messages bypass Trash). Requires force:true to delete non-interactively. Use gog_gmail_trash for normal deletes.',
|
|
185
555
|
annotations: { destructiveHint: true },
|
|
186
556
|
inputSchema: {
|
|
187
557
|
messageIds: z.array(z.string()).min(1).describe('Message IDs to permanently delete'),
|
|
558
|
+
force: z.boolean().optional().describe('Required to delete in this non-interactive context — without it the delete is refused as a safety guard.'),
|
|
188
559
|
account: accountParam,
|
|
189
560
|
},
|
|
190
|
-
}, async ({ messageIds, account }) => {
|
|
191
|
-
|
|
561
|
+
}, async ({ messageIds, force, account }) => {
|
|
562
|
+
const args = ['gmail', 'batch', 'delete', ...messageIds];
|
|
563
|
+
if (force) args.push('--force');
|
|
564
|
+
return runOrDiagnose(args, { account });
|
|
192
565
|
});
|
|
193
566
|
|
|
194
567
|
server.registerTool('gog_gmail_batch_modify', {
|
|
@@ -248,12 +621,12 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
248
621
|
});
|
|
249
622
|
|
|
250
623
|
server.registerTool('gog_gmail_thread_attachments', {
|
|
251
|
-
description: 'List all attachments in a Gmail thread, optionally downloading them.',
|
|
624
|
+
description: 'List all attachments in a Gmail thread, optionally downloading them. NOTE: download/outDir write to the CONNECTOR/gog server\'s filesystem — on the remote connector you can\'t read those files, so use gog_gmail_attachment per attachment to receive bytes (image inline, or a Drive link). This tool is best used just to LIST attachments (filenames, ids, sizes) and then fetch the ones you want individually.',
|
|
252
625
|
annotations: { readOnlyHint: true },
|
|
253
626
|
inputSchema: {
|
|
254
627
|
threadId: z.string().describe('Gmail thread ID'),
|
|
255
|
-
download: z.boolean().optional().describe('Download all attachments'),
|
|
256
|
-
outDir: z.string().optional().describe('Directory to write attachments to (default: current directory)'),
|
|
628
|
+
download: z.boolean().optional().describe('Download all attachments to the SERVER filesystem (see the note above; on the remote connector the files aren\'t reachable — fetch individually with gog_gmail_attachment instead).'),
|
|
629
|
+
outDir: z.string().optional().describe('Directory to write attachments to, resolved on the gog SERVER\'s filesystem (default: current directory). Not your local machine on the remote connector.'),
|
|
257
630
|
account: accountParam,
|
|
258
631
|
},
|
|
259
632
|
}, async ({ threadId, download, outDir, account }) => {
|
|
@@ -314,7 +687,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
314
687
|
account: accountParam,
|
|
315
688
|
},
|
|
316
689
|
}, async ({ labelIdOrName, account }) => {
|
|
317
|
-
return runOrDiagnose(['gmail', 'labels', 'delete', labelIdOrName], { account });
|
|
690
|
+
return runOrDiagnose(['gmail', 'labels', 'delete', labelIdOrName, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
|
|
318
691
|
});
|
|
319
692
|
|
|
320
693
|
server.registerTool('gog_gmail_labels_modify', {
|
|
@@ -369,12 +742,14 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
369
742
|
cc: z.string().optional().describe('CC recipients (comma-separated)'),
|
|
370
743
|
bcc: z.string().optional().describe('BCC recipients (comma-separated)'),
|
|
371
744
|
subject: z.string().describe('Subject'),
|
|
372
|
-
body: z.string().describe('Body (plain text)'),
|
|
373
|
-
bodyHtml: z.string().optional().describe('Body (HTML; optional)'),
|
|
745
|
+
body: z.string().describe('Body (plain text). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
|
|
746
|
+
bodyHtml: z.string().optional().describe('Body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
|
|
747
|
+
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server to use as the HTML body, or "-" to read from stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
|
|
374
748
|
replyToMessageId: z.string().optional().describe('Reply to a specific Gmail MESSAGE id — the short hex `id` field from gog_gmail_get / _search / _thread_get (e.g. 19e7593d77fd9636), NOT a thread id and NOT the RFC822 `<…@host>` Message-Id header. Anchors In-Reply-To/References to that exact message. To reply to a thread when you don\'t know the latest message, use replyToThreadId instead. If both are given, replyToMessageId wins.'),
|
|
375
749
|
replyToThreadId: z.string().optional().describe('Reply to a Gmail THREAD id — passed to gog as --thread-id, which threads the draft using the thread\'s latest-message headers (In-Reply-To/References). This is what "reply to this thread" almost always means. Mutually exclusive with replyToMessageId (which wins if both are set). Thread ids and message ids are both 16-hex strings and easy to confuse — use this param, not replyToMessageId, when the id came from a thread.'),
|
|
376
750
|
replyTo: z.string().optional().describe('Reply-To header address'),
|
|
377
751
|
quote: z.boolean().optional().describe('Include quoted original message in reply (requires replyToMessageId or replyToThreadId)'),
|
|
752
|
+
replyAll: z.boolean().optional().describe('Auto-populate recipients from the original message (reply-all), inferring To/Cc from it. Requires replyToMessageId or replyToThreadId. Explicit to/cc/bcc still apply on top; omitRecipients still suppresses them.'),
|
|
378
753
|
attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension. The JSON result echoes attached filenames and byte sizes — check it to confirm the files were found and embedded. On gog_gmail_drafts_update, supplying attach REPLACES the draft\'s existing attachments; omitting it preserves them (use clearAttachments to remove all).'),
|
|
379
754
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
380
755
|
omitRecipients: z.boolean().optional().describe('Create the draft with no recipients even if to/cc/bcc are supplied — an accidental-send guard. Populate recipients in a later update before sending.'),
|
|
@@ -389,29 +764,37 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
389
764
|
subject: string;
|
|
390
765
|
body: string;
|
|
391
766
|
bodyHtml?: string;
|
|
767
|
+
bodyHtmlFile?: string;
|
|
392
768
|
replyToMessageId?: string;
|
|
393
769
|
replyToThreadId?: string;
|
|
394
770
|
replyTo?: string;
|
|
395
771
|
quote?: boolean;
|
|
772
|
+
replyAll?: boolean;
|
|
396
773
|
attach?: string[];
|
|
397
774
|
from?: string;
|
|
398
775
|
omitRecipients?: boolean;
|
|
399
776
|
};
|
|
400
777
|
|
|
401
|
-
function appendDraftFlags(args:
|
|
778
|
+
function appendDraftFlags(args: GogArg[], f: DraftFlags): void {
|
|
779
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
402
780
|
if (!f.omitRecipients) {
|
|
403
781
|
if (f.to) args.push(`--to=${f.to}`);
|
|
404
782
|
if (f.cc) args.push(`--cc=${f.cc}`);
|
|
405
783
|
if (f.bcc) args.push(`--bcc=${f.bcc}`);
|
|
406
784
|
}
|
|
407
785
|
args.push(`--subject=${f.subject}`);
|
|
408
|
-
|
|
409
|
-
|
|
786
|
+
// body is required, so this is an either/or swap rather than an extra push:
|
|
787
|
+
// --body and --body-file together are a hard error in gog.
|
|
788
|
+
args.push(payloadArg('body', 'body-file', f.body));
|
|
789
|
+
if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
|
|
790
|
+
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
410
791
|
// A draft can reply to a specific message (--reply-to-message-id) or thread
|
|
411
792
|
// off the latest message in a thread (--thread-id, which gog resolves
|
|
412
793
|
// server-side). replyToMessageId wins when both are supplied.
|
|
413
794
|
if (f.replyToMessageId) args.push(`--reply-to-message-id=${f.replyToMessageId}`);
|
|
414
795
|
else if (f.replyToThreadId) args.push(`--thread-id=${f.replyToThreadId}`);
|
|
796
|
+
// --reply-all infers original recipients; gog requires a reply target above.
|
|
797
|
+
if (f.replyAll) args.push('--reply-all');
|
|
415
798
|
if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
|
|
416
799
|
if (f.quote) args.push('--quote');
|
|
417
800
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
@@ -424,20 +807,20 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
424
807
|
// front; for creates it's read from the write response's draftId. Degrades to
|
|
425
808
|
// the raw write result if the id can't be determined.
|
|
426
809
|
async function writeDraft(
|
|
427
|
-
args:
|
|
810
|
+
args: GogArg[],
|
|
428
811
|
account: string | undefined,
|
|
429
812
|
returnFull: boolean | undefined,
|
|
430
813
|
knownDraftId?: string,
|
|
431
|
-
): Promise<
|
|
814
|
+
): Promise<CallToolResult> {
|
|
432
815
|
const result = await runOrDiagnose(args, { account });
|
|
433
816
|
if (!returnFull) return result;
|
|
434
817
|
// The write must have returned a JSON acknowledgement before we re-fetch.
|
|
435
|
-
// A failed write (an error
|
|
818
|
+
// A failed write (an error result, not JSON) is surfaced as-is rather
|
|
436
819
|
// than masked by re-fetching the unchanged draft — this matters for the
|
|
437
820
|
// update path, where a known draftId would otherwise re-fetch a stale draft.
|
|
438
821
|
let parsed: { draftId?: string };
|
|
439
822
|
try {
|
|
440
|
-
parsed = JSON.parse(result
|
|
823
|
+
parsed = JSON.parse(resultText(result) ?? '') as { draftId?: string };
|
|
441
824
|
} catch {
|
|
442
825
|
return result;
|
|
443
826
|
}
|
|
@@ -450,7 +833,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
450
833
|
description: 'Create a new Gmail draft. Recipients (to/cc/bcc) are optional; omit them (or set omitRecipients) to create a recipient-less draft as an accidental-send guard. For replies, prefer replyToThreadId (anchors to the thread\'s latest message) or replyToMessageId (a specific message) — don\'t pass a thread id into replyToMessageId, which mis-threads silently.',
|
|
451
834
|
inputSchema: draftWriteSchema,
|
|
452
835
|
}, async ({ account, returnFull, ...flags }) => {
|
|
453
|
-
const args = ['gmail', 'drafts', 'create'];
|
|
836
|
+
const args: GogArg[] = ['gmail', 'drafts', 'create'];
|
|
454
837
|
appendDraftFlags(args, flags);
|
|
455
838
|
return writeDraft(args, account, returnFull);
|
|
456
839
|
});
|
|
@@ -464,7 +847,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
464
847
|
clearAttachments: z.boolean().optional().describe('Remove all attachments from the draft. By default, omitting attach preserves the draft\'s existing attachments; this intentionally clears them. Ignored if attach is also supplied (attach replaces).'),
|
|
465
848
|
},
|
|
466
849
|
}, async ({ draftId, account, returnFull, clearAttachments, ...flags }) => {
|
|
467
|
-
const args = ['gmail', 'drafts', 'update', draftId];
|
|
850
|
+
const args: GogArg[] = ['gmail', 'drafts', 'update', draftId];
|
|
468
851
|
appendDraftFlags(args, flags);
|
|
469
852
|
if (clearAttachments) args.push('--clear-attachments');
|
|
470
853
|
return writeDraft(args, account, returnFull, draftId);
|
|
@@ -509,15 +892,95 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
509
892
|
account: accountParam,
|
|
510
893
|
},
|
|
511
894
|
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, account }) => {
|
|
512
|
-
const args = ['gmail', 'forward', messageId, `--to=${to}`];
|
|
895
|
+
const args: GogArg[] = ['gmail', 'forward', messageId, `--to=${to}`];
|
|
513
896
|
if (cc) args.push(`--cc=${cc}`);
|
|
514
897
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
515
|
-
if (note) args.push(
|
|
898
|
+
if (note) args.push(payloadArg('note', 'note-file', note));
|
|
516
899
|
if (from) args.push(`--from=${from}`);
|
|
517
900
|
if (skipAttachments) args.push('--skip-attachments');
|
|
518
901
|
return runOrDiagnose(args, { account });
|
|
519
902
|
});
|
|
520
903
|
|
|
904
|
+
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
905
|
+
// only in the subcommand and default recipient set (reply → sender; reply-all
|
|
906
|
+
// → every participant). Recipient flags are repeatable on the CLI, so they are
|
|
907
|
+
// arrays here. --to/--cc/--bcc ADD or MOVE recipients onto the inherited reply
|
|
908
|
+
// set; --remove drops them. Body/HTML follow the same inline-or-file shape as
|
|
909
|
+
// the draft tools.
|
|
910
|
+
const replySchema = {
|
|
911
|
+
messageId: z.string().describe('Gmail message ID to reply to — the short hex `id` from gog_gmail_get / _search / _messages_search (NOT the threadId, NOT the RFC822 `<…@host>` Message-Id header).'),
|
|
912
|
+
body: z.string().optional().describe('Reply body (plain text; required unless bodyHtml or bodyHtmlFile is set). Any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Note gog strips trailing newlines from a file-delivered body.'),
|
|
913
|
+
bodyHtml: z.string().optional().describe('Reply body (HTML; optional). Pass the HTML itself at any size — a large body is written to a temp file on the gog server rather than inlined into the command line. Mutually exclusive with bodyHtmlFile.'),
|
|
914
|
+
bodyHtmlFile: z.string().optional().describe('Path to an HTML file that ALREADY EXISTS on the gog server for the reply body, or "-" for stdin. Mutually exclusive with bodyHtml — supplying both is rejected. You rarely need this: bodyHtml handles large bodies on its own.'),
|
|
915
|
+
to: z.array(z.string()).optional().describe('Add or move recipients to To (repeatable). Added on top of the recipients inherited from the original message.'),
|
|
916
|
+
cc: z.array(z.string()).optional().describe('Add or move recipients to Cc (repeatable)'),
|
|
917
|
+
bcc: z.array(z.string()).optional().describe('Add or move recipients to Bcc (repeatable)'),
|
|
918
|
+
remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
|
|
919
|
+
subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
920
|
+
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
921
|
+
attach: z.array(z.string()).optional().describe('Local file paths to attach (repeatable). Read on the gog server, base64-encoded with a MIME type inferred from the extension.'),
|
|
922
|
+
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
923
|
+
signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
|
|
924
|
+
signatureFrom: z.string().optional().describe('Append the Gmail signature from this send-as email address'),
|
|
925
|
+
signatureFile: z.string().optional().describe('Append a local signature file (plain text or HTML), read on the gog server'),
|
|
926
|
+
account: accountParam,
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
type ReplyFlags = {
|
|
930
|
+
body?: string;
|
|
931
|
+
bodyHtml?: string;
|
|
932
|
+
bodyHtmlFile?: string;
|
|
933
|
+
to?: string[];
|
|
934
|
+
cc?: string[];
|
|
935
|
+
bcc?: string[];
|
|
936
|
+
remove?: string[];
|
|
937
|
+
subject?: string;
|
|
938
|
+
noQuote?: boolean;
|
|
939
|
+
attach?: string[];
|
|
940
|
+
from?: string;
|
|
941
|
+
signature?: boolean;
|
|
942
|
+
signatureFrom?: string;
|
|
943
|
+
signatureFile?: string;
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
947
|
+
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
948
|
+
if (f.body) args.push(payloadArg('body', 'body-file', f.body));
|
|
949
|
+
if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
|
|
950
|
+
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
951
|
+
if (f.to) for (const r of f.to) args.push(`--to=${r}`);
|
|
952
|
+
if (f.cc) for (const r of f.cc) args.push(`--cc=${r}`);
|
|
953
|
+
if (f.bcc) for (const r of f.bcc) args.push(`--bcc=${r}`);
|
|
954
|
+
if (f.remove) for (const r of f.remove) args.push(`--remove=${r}`);
|
|
955
|
+
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
956
|
+
if (f.noQuote) args.push('--no-quote');
|
|
957
|
+
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
958
|
+
if (f.from) args.push(`--from=${f.from}`);
|
|
959
|
+
if (f.signature) args.push('--signature');
|
|
960
|
+
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
961
|
+
if (f.signatureFile) args.push(`--signature-file=${f.signatureFile}`);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
server.registerTool('gog_gmail_reply', {
|
|
965
|
+
description: 'Reply to a Gmail message (sends to the original sender only). Threads off the message and inherits a "Re:" subject and the quoted original by default. For replying to every participant use gog_gmail_reply_all; to reply across many messages matching a query use gog_gmail_autoreply; to stage a reply without sending use gog_gmail_drafts_create.',
|
|
966
|
+
annotations: { destructiveHint: true },
|
|
967
|
+
inputSchema: replySchema,
|
|
968
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
969
|
+
const args: GogArg[] = ['gmail', 'reply', messageId];
|
|
970
|
+
appendReplyFlags(args, flags);
|
|
971
|
+
return runOrDiagnose(args, { account });
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
server.registerTool('gog_gmail_reply_all', {
|
|
975
|
+
description: 'Reply to all participants of a Gmail message (sender plus every To/Cc recipient). Same inherited "Re:" subject and quoting as gog_gmail_reply. Use the remove flag to drop specific recipients from the reply-all.',
|
|
976
|
+
annotations: { destructiveHint: true },
|
|
977
|
+
inputSchema: replySchema,
|
|
978
|
+
}, async ({ messageId, account, ...flags }) => {
|
|
979
|
+
const args: GogArg[] = ['gmail', 'reply-all', messageId];
|
|
980
|
+
appendReplyFlags(args, flags);
|
|
981
|
+
return runOrDiagnose(args, { account });
|
|
982
|
+
});
|
|
983
|
+
|
|
521
984
|
server.registerTool('gog_gmail_autoreply', {
|
|
522
985
|
description: 'Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.',
|
|
523
986
|
annotations: { destructiveHint: true },
|
|
@@ -537,10 +1000,13 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
537
1000
|
account: accountParam,
|
|
538
1001
|
},
|
|
539
1002
|
}, async ({ query, max, subject, body, bodyHtml, from, replyTo, label, archive, markRead, skipBulk, allowSelf, account }) => {
|
|
540
|
-
const args = ['gmail', 'autoreply', query];
|
|
1003
|
+
const args: GogArg[] = ['gmail', 'autoreply', query];
|
|
541
1004
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
542
1005
|
if (subject) args.push(`--subject=${subject}`);
|
|
543
|
-
if (body) args.push(
|
|
1006
|
+
if (body) args.push(payloadArg('body', 'body-file', body));
|
|
1007
|
+
// `gmail autoreply` has --body-file but NO --body-html-file (verified against
|
|
1008
|
+
// gog 0.34.1), so an HTML autoreply body stays inline and is still bounded by
|
|
1009
|
+
// the runner's per-arg cap. Route it through payloadArg if gog ever adds one.
|
|
544
1010
|
if (bodyHtml) args.push(`--body-html=${bodyHtml}`);
|
|
545
1011
|
if (from) args.push(`--from=${from}`);
|
|
546
1012
|
if (replyTo) args.push(`--reply-to=${replyTo}`);
|
|
@@ -624,6 +1090,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
624
1090
|
if (enable) args.push('--enable');
|
|
625
1091
|
if (disable) args.push('--disable');
|
|
626
1092
|
if (subject) args.push(`--subject=${subject}`);
|
|
1093
|
+
// `gmail settings vacation update` exposes only --body — there is no
|
|
1094
|
+
// --body-file variant (verified against gog 0.34.1), so this stays inline.
|
|
627
1095
|
if (body) args.push(`--body=${body}`);
|
|
628
1096
|
if (start) args.push(`--start=${start}`);
|
|
629
1097
|
if (end) args.push(`--end=${end}`);
|
|
@@ -687,7 +1155,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
687
1155
|
if (important) args.push('--important');
|
|
688
1156
|
if (trash) args.push('--trash');
|
|
689
1157
|
if (neverSpam) args.push('--never-spam');
|
|
690
|
-
if (forward) args.push(`--forward=${forward}
|
|
1158
|
+
if (forward) args.push(`--forward=${forward}`, '--force'); // gog gates this op; without --force the runner's --no-input makes it refuse (forwarding filters only)
|
|
691
1159
|
return runOrDiagnose(args, { account });
|
|
692
1160
|
});
|
|
693
1161
|
|
|
@@ -699,7 +1167,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
699
1167
|
account: accountParam,
|
|
700
1168
|
},
|
|
701
1169
|
}, async ({ filterId, account }) => {
|
|
702
|
-
return runOrDiagnose(['gmail', 'settings', 'filters', 'delete', filterId], { account });
|
|
1170
|
+
return runOrDiagnose(['gmail', 'settings', 'filters', 'delete', filterId, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
|
|
703
1171
|
});
|
|
704
1172
|
|
|
705
1173
|
server.registerTool('gog_gmail_sendas_list', {
|
|
@@ -772,7 +1240,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
772
1240
|
account: accountParam,
|
|
773
1241
|
},
|
|
774
1242
|
}, async ({ email, account }) => {
|
|
775
|
-
return runOrDiagnose(['gmail', 'settings', 'sendas', 'delete', email], { account });
|
|
1243
|
+
return runOrDiagnose(['gmail', 'settings', 'sendas', 'delete', email, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
|
|
776
1244
|
});
|
|
777
1245
|
|
|
778
1246
|
server.registerTool('gog_gmail_sendas_verify', {
|