gogcli-mcp 4.2.5 → 4.4.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 +1340 -262
- package/dist/lib.js +1381 -260
- package/manifest.json +9 -2
- package/package.json +2 -2
- package/server.json +2 -2
- package/src/arg-guard.ts +68 -0
- package/src/argv.ts +27 -0
- package/src/attachment-root.ts +111 -0
- package/src/attachments.ts +1 -0
- package/src/blob-upload.ts +4 -2
- package/src/dispatch-confirmation.ts +277 -0
- package/src/file-roots.ts +66 -0
- package/src/gmail-dispatch-guard.ts +70 -30
- package/src/gmail-results.ts +21 -2
- package/src/lib.ts +15 -0
- package/src/run-path-guard.ts +118 -0
- package/src/runner.ts +86 -18
- package/src/send-confirm-token.ts +167 -0
- package/src/tools/api.ts +60 -7
- package/src/tools/appscript.ts +12 -8
- package/src/tools/auth.ts +13 -3
- package/src/tools/calendar.ts +178 -15
- package/src/tools/chat.ts +94 -18
- package/src/tools/classroom.ts +110 -28
- package/src/tools/contacts.ts +5 -3
- package/src/tools/docs.ts +8 -6
- package/src/tools/drive.ts +96 -20
- package/src/tools/gmail.ts +175 -24
- package/src/tools/sheets.ts +10 -8
- package/src/tools/slides.ts +14 -9
- package/src/tools/tasks.ts +7 -5
- package/src/tools/utils.ts +52 -6
- package/tests/arg-guard.test.ts +80 -0
- package/tests/attachment-root.test.ts +130 -0
- package/tests/attachments.test.ts +8 -0
- package/tests/blob-upload.test.ts +4 -3
- package/tests/file-roots.test.ts +101 -0
- package/tests/gmail-dispatch-guard.test.ts +270 -11
- package/tests/gmail-results.test.ts +35 -1
- package/tests/run-path-guard.test.ts +142 -0
- package/tests/runner.test.ts +136 -0
- package/tests/send-confirm-token.test.ts +200 -0
- package/tests/tools/api.test.ts +74 -8
- package/tests/tools/appscript.test.ts +34 -8
- package/tests/tools/auth.test.ts +44 -17
- package/tests/tools/calendar.test.ts +33 -28
- package/tests/tools/chat.test.ts +50 -21
- package/tests/tools/classroom.test.ts +43 -39
- package/tests/tools/contacts.test.ts +3 -2
- package/tests/tools/dispatch-gates.test.ts +396 -0
- package/tests/tools/docs.test.ts +44 -15
- package/tests/tools/drive.test.ts +110 -20
- package/tests/tools/gmail-confirm-token.test.ts +274 -0
- package/tests/tools/gmail.test.ts +227 -29
- package/tests/tools/run-tool-examples.test.ts +69 -0
- package/tests/tools/run-vets.test.ts +131 -0
- package/tests/tools/sheets.test.ts +16 -15
- package/tests/tools/slides.test.ts +47 -11
- package/tests/tools/tasks.test.ts +7 -6
- package/tests/tools/utils.test.ts +32 -31
- package/vitest.config.ts +5 -0
package/src/tools/gmail.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { finalizeGmailSearch, fetchGmailPages } from '../gmail-results.js';
|
|
|
5
5
|
import type { GogArg } from '../runner.js';
|
|
6
6
|
import { attachInlineParam, inlineAttachmentArgs } from '../attachments.js';
|
|
7
7
|
import type { InlineAttachmentInput } from '../attachments.js';
|
|
8
|
-
import { extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText } from '../gmail-dispatch-guard.js';
|
|
8
|
+
import { attachmentDetails, attachmentNames, attachmentPreview, bodyPreview, CONFIRM_FALLBACK_DESCRIPTION, confirmTokenParam, extractEmails, logGmailDispatch, replyDispatchOp, requireGmailDispatchConfirmation, resultText, senderPreview } from '../gmail-dispatch-guard.js';
|
|
9
|
+
import { pos } from '../argv.js';
|
|
10
|
+
import { hasCommandWord } from '../dispatch-confirmation.js';
|
|
11
|
+
import { confinePath, confinePaths } from '../file-roots.js';
|
|
9
12
|
|
|
10
13
|
// gmail reply / reply-all share an identical flag set (gog 0.27+); they differ
|
|
11
14
|
// only in the subcommand and default recipient set (reply → sender; reply-all
|
|
@@ -24,7 +27,7 @@ export const replySchema = {
|
|
|
24
27
|
remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
|
|
25
28
|
subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
26
29
|
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
27
|
-
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 a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
|
|
30
|
+
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 a hosted deployment (e.g. mcp-host) these paths do not exist and the call fails with "no such file or directory" — use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
|
|
28
31
|
attachInline: attachInlineParam,
|
|
29
32
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
30
33
|
autoFromAddressedAlias: z.boolean().optional().describe('When from is omitted, send from the verified send-as alias the original message was addressed TO, instead of the account\'s primary address — so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set.'),
|
|
@@ -53,8 +56,18 @@ export type ReplyFlags = {
|
|
|
53
56
|
signatureFile?: string;
|
|
54
57
|
};
|
|
55
58
|
|
|
59
|
+
// Every server path a reply/draft names must sit inside GOG_FILE_ROOTS: each is
|
|
60
|
+
// read on the gog host and mailed out, so unconfined it is a file-exfiltration
|
|
61
|
+
// primitive (audit SEC-3). Checked before anything else touches gog.
|
|
62
|
+
export function confineReplyPaths(f: Pick<ReplyFlags, 'attach' | 'bodyHtmlFile' | 'signatureFile'>): void {
|
|
63
|
+
confinePaths(f.attach, 'attach');
|
|
64
|
+
if (f.bodyHtmlFile) confinePath(f.bodyHtmlFile, 'bodyHtmlFile');
|
|
65
|
+
if (f.signatureFile) confinePath(f.signatureFile, 'signatureFile');
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
57
69
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', f.bodyHtml, f.bodyHtmlFile);
|
|
70
|
+
confineReplyPaths(f);
|
|
58
71
|
if (f.body) args.push(payloadArg('body', 'body-file', f.body));
|
|
59
72
|
if (f.bodyHtml) args.push(payloadArg('body-html', 'body-html-file', f.bodyHtml, 'html'));
|
|
60
73
|
else if (f.bodyHtmlFile) args.push(`--body-html-file=${f.bodyHtmlFile}`);
|
|
@@ -86,8 +99,9 @@ export function appendReplyFlags(args: GogArg[], f: ReplyFlags): void {
|
|
|
86
99
|
// The send-side reply/reply-all schema, distinct from the shared replySchema
|
|
87
100
|
// above. gog_gmail_drafts_reply / _reply_all (gogcli-mcp-gmail) reuse
|
|
88
101
|
// replySchema verbatim and must never gain a send-confirmation input — draft
|
|
89
|
-
// tools stage mail, while send tools request confirmation through MCP
|
|
90
|
-
|
|
102
|
+
// tools stage mail, while send tools request confirmation (through MCP, or the
|
|
103
|
+
// opt-in confirmToken fallback, which is why only THIS schema carries it).
|
|
104
|
+
const sendReplySchema = z.object({ ...replySchema, confirmToken: confirmTokenParam });
|
|
91
105
|
|
|
92
106
|
// gog's own `reply`/`reply-all` response never echoes the resolved
|
|
93
107
|
// recipients when replying to a single message (the common case: gog only
|
|
@@ -136,6 +150,62 @@ export function computeReplyRecipients(
|
|
|
136
150
|
return emails;
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
/** `message.threadId` out of `gog gmail get --format=metadata --json`, if present. */
|
|
154
|
+
export function metadataThreadId(raw: string): string | undefined {
|
|
155
|
+
try {
|
|
156
|
+
const threadId = (JSON.parse(raw) as { message?: { threadId?: unknown } } | null)?.message?.threadId;
|
|
157
|
+
return typeof threadId === 'string' ? threadId : undefined;
|
|
158
|
+
} catch {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The token fallback's preview and bound payload for a reply. To/Cc/Bcc are the
|
|
164
|
+
// same approximation computeReplyRecipients makes (gog composes the final list),
|
|
165
|
+
// split by field so the user can see who is on Bcc.
|
|
166
|
+
function replyTokenSubject(
|
|
167
|
+
kind: 'reply' | 'reply-all',
|
|
168
|
+
messageId: string,
|
|
169
|
+
account: string | undefined,
|
|
170
|
+
headers: Record<string, string>,
|
|
171
|
+
threadId: string | undefined,
|
|
172
|
+
flags: ReplyFlags,
|
|
173
|
+
) {
|
|
174
|
+
const removed = new Set(extractEmails(...(flags.remove ?? [])));
|
|
175
|
+
const keep = (emails: string[]) => emails.filter((e) => !removed.has(e));
|
|
176
|
+
const to = keep(extractEmails(headers.from, ...(kind === 'reply-all' ? [headers.to] : []), ...(flags.to ?? [])));
|
|
177
|
+
const cc = keep(extractEmails(...(kind === 'reply-all' ? [headers.cc] : []), ...(flags.cc ?? [])));
|
|
178
|
+
const bcc = keep(extractEmails(...(flags.bcc ?? [])));
|
|
179
|
+
const attachments = attachmentDetails(flags.attach, flags.attachInline);
|
|
180
|
+
const subject = flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined);
|
|
181
|
+
const from = flags.from ?? (flags.autoFromAddressedAlias ? 'the send-as alias the original was addressed to' : senderPreview(account));
|
|
182
|
+
const localFiles = attachmentDetails([flags.bodyHtmlFile, flags.signatureFile].filter((p): p is string => Boolean(p)), undefined);
|
|
183
|
+
return {
|
|
184
|
+
target: messageId,
|
|
185
|
+
payload: {
|
|
186
|
+
kind, to, cc, bcc, subject, from,
|
|
187
|
+
body: flags.body, bodyHtml: flags.bodyHtml, localFiles,
|
|
188
|
+
signature: Boolean(flags.signature), signatureFrom: flags.signatureFrom,
|
|
189
|
+
attachments, quote: !flags.noQuote,
|
|
190
|
+
threadId, inReplyTo: headers.message_id, references: headers.references,
|
|
191
|
+
},
|
|
192
|
+
preview: {
|
|
193
|
+
from, to, cc, bcc, subject,
|
|
194
|
+
body: flags.body,
|
|
195
|
+
...(flags.bodyHtml ? { bodyHtml: flags.bodyHtml } : {}),
|
|
196
|
+
...(flags.bodyHtmlFile ? { bodyHtmlFile: flags.bodyHtmlFile } : {}),
|
|
197
|
+
...(flags.signature || flags.signatureFrom || flags.signatureFile
|
|
198
|
+
? { signature: flags.signatureFile ?? flags.signatureFrom ?? 'the Gmail signature of the sending address' }
|
|
199
|
+
: {}),
|
|
200
|
+
quotesOriginal: !flags.noQuote,
|
|
201
|
+
attachments: attachmentPreview(attachments),
|
|
202
|
+
replyingToMessageId: messageId,
|
|
203
|
+
threadId,
|
|
204
|
+
inReplyTo: headers.message_id,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
139
209
|
// Shared by gog_gmail_reply and gog_gmail_reply_all: fetch the target
|
|
140
210
|
// message's headers (always — the prompt needs them and so does the audit
|
|
141
211
|
// log on the accepted path), then confirm and send.
|
|
@@ -149,28 +219,71 @@ async function sendReply(
|
|
|
149
219
|
account: string | undefined,
|
|
150
220
|
flags: ReplyFlags,
|
|
151
221
|
ctx: ServerContext,
|
|
222
|
+
confirmToken?: string,
|
|
152
223
|
) {
|
|
153
|
-
const metaResult = await runOrDiagnose(['gmail', 'get', messageId, '--format=metadata'], { account });
|
|
224
|
+
const metaResult = await runOrDiagnose(['gmail', 'get', pos(messageId), '--format=metadata'], { account });
|
|
154
225
|
if (metaResult.isError) return metaResult;
|
|
155
|
-
const
|
|
226
|
+
const metaText = resultText(metaResult);
|
|
227
|
+
const headers = parseMetadataHeaders(metaText);
|
|
156
228
|
const recipients = computeReplyRecipients(kind, headers, flags);
|
|
157
|
-
const confirmation = requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
229
|
+
const confirmation = await requireGmailDispatchConfirmation(ctx, replyDispatchOp(kind), {
|
|
158
230
|
messageId,
|
|
159
231
|
recipients,
|
|
160
232
|
recipientCount: recipients.length,
|
|
161
233
|
subject: flags.subject || (headers.subject ? `Re: ${headers.subject}` : undefined),
|
|
162
234
|
quoting: !flags.noQuote,
|
|
163
235
|
bodyLength: (flags.body ?? flags.bodyHtml ?? '').length,
|
|
236
|
+
bodyPreview: bodyPreview(flags.body ?? flags.bodyHtml),
|
|
237
|
+
bodyHtmlFile: flags.bodyHtmlFile,
|
|
164
238
|
attachmentCount: (flags.attach?.length ?? 0) + (flags.attachInline?.length ?? 0),
|
|
239
|
+
attachments: attachmentNames(flags.attach, flags.attachInline),
|
|
240
|
+
}, {
|
|
241
|
+
tool: toolName,
|
|
242
|
+
account,
|
|
243
|
+
confirmToken,
|
|
244
|
+
// The metadata read above IS the phase-2 re-read: it runs on every call,
|
|
245
|
+
// so the original's Message-ID/References are fresh here.
|
|
246
|
+
subject: () => replyTokenSubject(kind, messageId, account, headers, metadataThreadId(metaText), flags),
|
|
165
247
|
});
|
|
166
248
|
if (confirmation) return confirmation;
|
|
167
|
-
const args: GogArg[] = ['gmail', kind, messageId];
|
|
249
|
+
const args: GogArg[] = ['gmail', kind, pos(messageId)];
|
|
168
250
|
appendReplyFlags(args, flags);
|
|
169
251
|
const result = await runOrDiagnose(args, { account });
|
|
170
252
|
if (!result.isError) logGmailDispatch(toolName, recipients, account);
|
|
171
253
|
return result;
|
|
172
254
|
}
|
|
173
255
|
|
|
256
|
+
// What --gmail-no-send does NOT cover (verified on gog 0.41.0): a bulk
|
|
257
|
+
// auto-reply, and the settings that route future mail to someone else —
|
|
258
|
+
// forwarding addresses, auto-forwarding, filters (which can forward) and
|
|
259
|
+
// delegates (which grant another account the mailbox). Each has a dedicated,
|
|
260
|
+
// reviewable tool; none may ride the escape hatch.
|
|
261
|
+
//
|
|
262
|
+
// gog accepts these both under `settings` AND one level up, as
|
|
263
|
+
// `gog gmail filters|forwarding|autoforward|delegates ...` (left out of
|
|
264
|
+
// `gog schema`, but they reach Google), so both spellings are refused.
|
|
265
|
+
const GMAIL_RUN_BLOCKED_SETTINGS = new Set(['forwarding', 'autoforward', 'filters', 'delegates']);
|
|
266
|
+
|
|
267
|
+
export function vetGmailRun(subcommand: string, args: readonly string[]): string | undefined {
|
|
268
|
+
if (subcommand === 'autoreply') {
|
|
269
|
+
return 'gog gmail autoreply sends mail and is not available through gog_gmail_run. Use gog_gmail_autoreply, which asks the user to confirm.';
|
|
270
|
+
}
|
|
271
|
+
if (GMAIL_RUN_BLOCKED_SETTINGS.has(subcommand)) {
|
|
272
|
+
return `gog gmail ${subcommand} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
273
|
+
}
|
|
274
|
+
if (subcommand === 'settings') {
|
|
275
|
+
// A global flag can take its value as the next token (`settings --color
|
|
276
|
+
// never filters ...`), so the word is not necessarily args[0]. Refuse it
|
|
277
|
+
// wherever it appears; a legitimate settings call carrying one of these
|
|
278
|
+
// words as a value is rare and has a dedicated tool anyway.
|
|
279
|
+
const blocked = hasCommandWord(args, GMAIL_RUN_BLOCKED_SETTINGS);
|
|
280
|
+
if (blocked) {
|
|
281
|
+
return `gog gmail settings ${blocked} can forward or hand over mail and is not available through gog_gmail_run. Use the dedicated gog_gmail_* tool instead.`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
174
287
|
export function registerGmailTools(server: McpServer): void {
|
|
175
288
|
server.registerTool('gog_gmail_search', {
|
|
176
289
|
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). '
|
|
@@ -189,7 +302,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
189
302
|
account: accountParam,
|
|
190
303
|
}),
|
|
191
304
|
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
192
|
-
const args = ['gmail', 'search', query];
|
|
305
|
+
const args: GogArg[] = ['gmail', 'search', pos(query)];
|
|
193
306
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
194
307
|
if (all) args.push('--all');
|
|
195
308
|
if (fromContact) args.push(`--from-contact=${fromContact}`);
|
|
@@ -226,7 +339,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
226
339
|
account: accountParam,
|
|
227
340
|
}),
|
|
228
341
|
}, async ({ messageId, format, sanitizeContent, account }) => {
|
|
229
|
-
const args = ['gmail', 'get', messageId];
|
|
342
|
+
const args: GogArg[] = ['gmail', 'get', pos(messageId)];
|
|
230
343
|
if (format) args.push(`--format=${format}`);
|
|
231
344
|
if (sanitizeContent) args.push('--sanitize-content');
|
|
232
345
|
return runOrDiagnose(args, { account });
|
|
@@ -234,7 +347,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
234
347
|
|
|
235
348
|
server.registerTool('gog_gmail_send', {
|
|
236
349
|
description:
|
|
237
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject,
|
|
350
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the recipients, subject, a preview of the body and the attachment names. '
|
|
238
351
|
+ 'Mail is sent only after the user accepts that prompt. '
|
|
239
352
|
+ 'Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and '
|
|
240
353
|
+ '`attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on '
|
|
@@ -244,7 +357,8 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
244
357
|
+ 'and byte sizes — check it to confirm the files were embedded. '
|
|
245
358
|
+ 'NOT the tool for answering a message: replyToMessageId only files this in the right thread — the '
|
|
246
359
|
+ 'subject, recipients and body are entirely yours, and the original is not quoted unless you set '
|
|
247
|
-
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
|
|
360
|
+
+ 'quote. Use gog_gmail_reply / gog_gmail_reply_all instead, which inherit all three.'
|
|
361
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
248
362
|
annotations: { destructiveHint: true },
|
|
249
363
|
inputSchema: z.object({
|
|
250
364
|
to: z.string().describe('Recipient(s), comma-separated'),
|
|
@@ -255,11 +369,13 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
255
369
|
replyToMessageId: z.string().optional().describe('Message ID to thread this message against — sets In-Reply-To/References only. It does NOT quote the original (pass quote for that), inherit its recipients, or prefix the subject with "Re:". For an actual reply use gog_gmail_reply.'),
|
|
256
370
|
threadId: z.string().optional().describe('Thread ID to thread this message within. Same caveat as replyToMessageId: threading only, no quote and no inherited subject or recipients.'),
|
|
257
371
|
quote: z.boolean().optional().describe('Include the original message quoted below the body. Requires replyToMessageId or threadId. gog quotes by DEFAULT on gmail reply but never on gmail send, so without this a threaded send arrives with the original nowhere in it.'),
|
|
258
|
-
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 a hosted deployment (e.g. mcp-host) 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.'),
|
|
372
|
+
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 a hosted deployment (e.g. mcp-host) 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. Must be inside the server\'s GOG_FILE_ROOTS directories (default ~/gogcli-mcp-files).'),
|
|
259
373
|
attachInline: attachInlineParam,
|
|
260
374
|
account: accountParam,
|
|
375
|
+
confirmToken: confirmTokenParam,
|
|
261
376
|
}),
|
|
262
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account }, ctx) => {
|
|
377
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, quote, attach, attachInline, account, confirmToken }, ctx) => {
|
|
378
|
+
confinePaths(attach, 'attach');
|
|
263
379
|
// Built (and validated — inlineAttachmentArgs throws on bad base64 or an
|
|
264
380
|
// oversize file) BEFORE the confirmation request, on both paths: a prompt that
|
|
265
381
|
// skipped this would tell a caller "looks fine, send it" about an
|
|
@@ -286,12 +402,35 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
286
402
|
args.push(...inline);
|
|
287
403
|
|
|
288
404
|
const recipients = extractEmails(to, cc, bcc);
|
|
289
|
-
const confirmation = requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
405
|
+
const confirmation = await requireGmailDispatchConfirmation(ctx, 'gmail.send', {
|
|
290
406
|
to, cc, bcc, recipients, recipientCount: recipients.length, subject,
|
|
291
407
|
bodyLength: body.length,
|
|
408
|
+
bodyPreview: bodyPreview(body),
|
|
292
409
|
threaded: Boolean(replyToMessageId || threadId),
|
|
293
410
|
quoting: Boolean(quote),
|
|
294
411
|
attachmentCount: (attach?.length ?? 0) + (attachInline?.length ?? 0),
|
|
412
|
+
attachments: attachmentNames(attach, attachInline),
|
|
413
|
+
}, {
|
|
414
|
+
tool: 'gog_gmail_send',
|
|
415
|
+
account,
|
|
416
|
+
confirmToken,
|
|
417
|
+
// Nothing is stored between the phases: the payload IS the arguments,
|
|
418
|
+
// so phase 2 must repeat them and any difference is a changed send.
|
|
419
|
+
subject: () => {
|
|
420
|
+
const attachments = attachmentDetails(attach, attachInline);
|
|
421
|
+
const from = senderPreview(account);
|
|
422
|
+
return {
|
|
423
|
+
target: replyToMessageId ?? threadId ?? '',
|
|
424
|
+
payload: { from, to, cc, bcc, subject, body, attachments, threadId, inReplyTo: replyToMessageId, quote: Boolean(quote) },
|
|
425
|
+
preview: {
|
|
426
|
+
from, to, cc, bcc, subject, body,
|
|
427
|
+
attachments: attachmentPreview(attachments),
|
|
428
|
+
threadId,
|
|
429
|
+
inReplyTo: replyToMessageId,
|
|
430
|
+
quotesOriginal: Boolean(quote),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
},
|
|
295
434
|
});
|
|
296
435
|
if (confirmation) return confirmation;
|
|
297
436
|
const result = await runOrDiagnose(args, { account });
|
|
@@ -317,7 +456,7 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
317
456
|
// ==========================================================================
|
|
318
457
|
server.registerTool('gog_gmail_reply', {
|
|
319
458
|
description:
|
|
320
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject,
|
|
459
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipient, subject, a preview of the body and the attachment names. '
|
|
321
460
|
+ 'Mail is sent only after the user accepts that prompt. To STAGE a reply instead '
|
|
322
461
|
+ 'of sending it, use gog_gmail_drafts_reply (gogcli-mcp-gmail only), which never needs confirmation. '
|
|
323
462
|
+ 'Reply to a Gmail message (goes to the original sender only). USE THIS, not gog_gmail_send, whenever you are '
|
|
@@ -326,29 +465,41 @@ export function registerGmailTools(server: McpServer): void {
|
|
|
326
465
|
+ 'brand-new message, with the original nowhere in it. To answer every participant use gog_gmail_reply_all. '
|
|
327
466
|
+ 'The gogcli-mcp-gmail package adds two more routes with the same composition: gog_gmail_autoreply to reply '
|
|
328
467
|
+ 'across every message matching a query, and gog_gmail_drafts_reply to stage this exact reply as a draft '
|
|
329
|
-
+ 'instead of sending it.'
|
|
468
|
+
+ 'instead of sending it.'
|
|
469
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
330
470
|
annotations: { destructiveHint: true },
|
|
331
471
|
inputSchema: sendReplySchema,
|
|
332
|
-
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
472
|
+
}, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
|
|
333
473
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
334
|
-
|
|
474
|
+
confineReplyPaths(flags);
|
|
475
|
+
return sendReply('reply', 'gog_gmail_reply', messageId, account, flags, ctx, confirmToken);
|
|
335
476
|
});
|
|
336
477
|
|
|
337
478
|
server.registerTool('gog_gmail_reply_all', {
|
|
338
479
|
description:
|
|
339
|
-
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject,
|
|
480
|
+
'SENDS MAIL — asks the MCP host to show a confirmation prompt with the resolved recipients, subject, a preview of the body and the attachment names. '
|
|
340
481
|
+ 'Mail is sent only after the user accepts that prompt. To STAGE a '
|
|
341
482
|
+ 'reply-all instead of sending it, use gog_gmail_drafts_reply_all (gogcli-mcp-gmail only), which never needs '
|
|
342
483
|
+ 'confirmation. '
|
|
343
484
|
+ 'Reply to all participants of a Gmail message (the sender plus every To/Cc recipient). Same inherited "Re:" '
|
|
344
485
|
+ 'subject and quoted original as gog_gmail_reply. Use the remove flag to drop specific recipients from the '
|
|
345
|
-
+ 'reply-all.'
|
|
486
|
+
+ 'reply-all.'
|
|
487
|
+
+ CONFIRM_FALLBACK_DESCRIPTION,
|
|
346
488
|
annotations: { destructiveHint: true },
|
|
347
489
|
inputSchema: sendReplySchema,
|
|
348
|
-
}, async ({ messageId, account, ...flags }, ctx) => {
|
|
490
|
+
}, async ({ messageId, account, confirmToken, ...flags }, ctx) => {
|
|
349
491
|
assertNotBoth('bodyHtml', 'bodyHtmlFile', flags.bodyHtml, flags.bodyHtmlFile);
|
|
350
|
-
|
|
492
|
+
confineReplyPaths(flags);
|
|
493
|
+
return sendReply('reply-all', 'gog_gmail_reply_all', messageId, account, flags, ctx, confirmToken);
|
|
351
494
|
});
|
|
352
495
|
|
|
353
|
-
registerRunTool(server, {
|
|
496
|
+
registerRunTool(server, {
|
|
497
|
+
service: 'gmail',
|
|
498
|
+
examples: '"archive", "mark-read", "labels"',
|
|
499
|
+
// gog's --gmail-no-send blocks send/reply/forward/drafts send and all of
|
|
500
|
+
// their aliases at runtime (verified on gog 0.41.0). Sending goes through the
|
|
501
|
+
// confirmed tools, never this escape hatch.
|
|
502
|
+
gmailNoSend: true,
|
|
503
|
+
vet: vetGmailRun,
|
|
504
|
+
});
|
|
354
505
|
}
|
package/src/tools/sheets.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { run } from '../runner.js';
|
|
|
4
4
|
import { rawTextResult } from '@chrischall/mcp-utils';
|
|
5
5
|
import { accountParam, runOrDiagnose, registerRunTool, diagnose } from './utils.js';
|
|
6
6
|
import { expandAnchorRange, countNonEmptyCells } from './sheets-a1.js';
|
|
7
|
+
import { pos } from '../argv.js';
|
|
8
|
+
import type { GogArg } from '../runner.js';
|
|
7
9
|
|
|
8
10
|
// Cell value type: matches what gog sheets --values-json accepts (passed
|
|
9
11
|
// straight to the Sheets API as userEnteredValue). Strings starting with
|
|
@@ -31,7 +33,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
31
33
|
account: accountParam,
|
|
32
34
|
}),
|
|
33
35
|
}, async ({ spreadsheetId, range, account }) => {
|
|
34
|
-
return runOrDiagnose(['sheets', 'get', spreadsheetId, range], { account });
|
|
36
|
+
return runOrDiagnose(['sheets', 'get', pos(spreadsheetId), pos(range)], { account });
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
server.registerTool('gog_sheets_update', {
|
|
@@ -52,7 +54,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
52
54
|
const readRange = expandAnchorRange(range, values.length, cols);
|
|
53
55
|
let existing: string;
|
|
54
56
|
try {
|
|
55
|
-
existing = await run(['sheets', 'get', spreadsheetId, readRange], { account });
|
|
57
|
+
existing = await run(['sheets', 'get', pos(spreadsheetId), pos(readRange)], { account });
|
|
56
58
|
} catch (err) {
|
|
57
59
|
// Couldn't read the target — refuse to write rather than risk an
|
|
58
60
|
// overwrite, and diagnose the read failure (auth/transient/etc.) the
|
|
@@ -70,7 +72,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
70
72
|
);
|
|
71
73
|
}
|
|
72
74
|
}
|
|
73
|
-
const args = ['sheets', 'update', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
75
|
+
const args: GogArg[] = ['sheets', 'update', pos(spreadsheetId), pos(range), `--values-json=${JSON.stringify(values)}`];
|
|
74
76
|
if (dry_run) args.push('--dry-run');
|
|
75
77
|
if (fail_on_formula_error) args.push('--fail-on-formula-error');
|
|
76
78
|
return runOrDiagnose(args, { account });
|
|
@@ -87,7 +89,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
87
89
|
account: accountParam,
|
|
88
90
|
}),
|
|
89
91
|
}, async ({ spreadsheetId, range, values, account, dry_run }) => {
|
|
90
|
-
const args = ['sheets', 'append', spreadsheetId, range, `--values-json=${JSON.stringify(values)}`];
|
|
92
|
+
const args: GogArg[] = ['sheets', 'append', pos(spreadsheetId), pos(range), `--values-json=${JSON.stringify(values)}`];
|
|
91
93
|
if (dry_run) args.push('--dry-run');
|
|
92
94
|
return runOrDiagnose(args, { account });
|
|
93
95
|
});
|
|
@@ -102,7 +104,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
102
104
|
account: accountParam,
|
|
103
105
|
}),
|
|
104
106
|
}, async ({ spreadsheetId, range, account, dry_run }) => {
|
|
105
|
-
const args = ['sheets', 'clear', spreadsheetId, range];
|
|
107
|
+
const args: GogArg[] = ['sheets', 'clear', pos(spreadsheetId), pos(range)];
|
|
106
108
|
if (dry_run) args.push('--dry-run');
|
|
107
109
|
return runOrDiagnose(args, { account });
|
|
108
110
|
});
|
|
@@ -115,7 +117,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
115
117
|
account: accountParam,
|
|
116
118
|
}),
|
|
117
119
|
}, async ({ spreadsheetId, account }) => {
|
|
118
|
-
return runOrDiagnose(['sheets', 'metadata', spreadsheetId], { account });
|
|
120
|
+
return runOrDiagnose(['sheets', 'metadata', pos(spreadsheetId)], { account });
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
server.registerTool('gog_sheets_create', {
|
|
@@ -126,7 +128,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
126
128
|
account: accountParam,
|
|
127
129
|
}),
|
|
128
130
|
}, async ({ title, account }) => {
|
|
129
|
-
return runOrDiagnose(['sheets', 'create', title], { account });
|
|
131
|
+
return runOrDiagnose(['sheets', 'create', pos(title)], { account });
|
|
130
132
|
});
|
|
131
133
|
|
|
132
134
|
server.registerTool('gog_sheets_find_replace', {
|
|
@@ -139,7 +141,7 @@ export function registerSheetsTools(server: McpServer): void {
|
|
|
139
141
|
account: accountParam,
|
|
140
142
|
}),
|
|
141
143
|
}, async ({ spreadsheetId, find, replace, account }) => {
|
|
142
|
-
return runOrDiagnose(['sheets', 'find-replace', spreadsheetId, find, replace], { account });
|
|
144
|
+
return runOrDiagnose(['sheets', 'find-replace', pos(spreadsheetId), pos(find), pos(replace)], { account });
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
registerRunTool(server, { service: 'sheets', examples: '"freeze", "add-tab", "rename-tab"' });
|
package/src/tools/slides.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
4
|
+
import { pos } from '../argv.js';
|
|
5
|
+
import { confinePath } from '../file-roots.js';
|
|
6
|
+
import type { GogArg } from '../runner.js';
|
|
4
7
|
|
|
5
8
|
export function registerSlidesTools(server: McpServer): void {
|
|
6
9
|
server.registerTool('gog_slides_export', {
|
|
7
|
-
description: 'Export a Google Slides presentation to a local file (pdf or pptx).',
|
|
8
|
-
|
|
10
|
+
description: 'Export a Google Slides presentation to a local file (pdf or pptx). The out path must be inside the server\'s GOG_FILE_ROOTS directories.',
|
|
11
|
+
// Writes a file on the gog host and can overwrite one: not read-only.
|
|
12
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
9
13
|
inputSchema: z.object({
|
|
10
14
|
presentationId: z.string().describe('Presentation ID'),
|
|
11
15
|
out: z.string().optional().describe('Output file path'),
|
|
@@ -14,7 +18,8 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
14
18
|
account: accountParam,
|
|
15
19
|
}),
|
|
16
20
|
}, async ({ presentationId, out, format, overwrite, account }) => {
|
|
17
|
-
|
|
21
|
+
if (out) confinePath(out, 'out');
|
|
22
|
+
const args: GogArg[] = ['slides', 'export', pos(presentationId)];
|
|
18
23
|
if (out) args.push(`--out=${out}`);
|
|
19
24
|
if (format) args.push(`--format=${format}`);
|
|
20
25
|
if (overwrite) args.push('--overwrite');
|
|
@@ -29,7 +34,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
29
34
|
account: accountParam,
|
|
30
35
|
}),
|
|
31
36
|
}, async ({ presentationId, account }) => {
|
|
32
|
-
return runOrDiagnose(['slides', 'info', presentationId], { account });
|
|
37
|
+
return runOrDiagnose(['slides', 'info', pos(presentationId)], { account });
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
server.registerTool('gog_slides_create', {
|
|
@@ -42,7 +47,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
42
47
|
account: accountParam,
|
|
43
48
|
}),
|
|
44
49
|
}, async ({ title, parent, template, account }) => {
|
|
45
|
-
const args = ['slides', 'create', title];
|
|
50
|
+
const args: GogArg[] = ['slides', 'create', pos(title)];
|
|
46
51
|
if (parent) args.push(`--parent=${parent}`);
|
|
47
52
|
if (template) args.push(`--template=${template}`);
|
|
48
53
|
return runOrDiagnose(args, { account });
|
|
@@ -58,7 +63,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
58
63
|
account: accountParam,
|
|
59
64
|
}),
|
|
60
65
|
}, async ({ presentationId, title, parent, account }) => {
|
|
61
|
-
const args = ['slides', 'copy', presentationId, title];
|
|
66
|
+
const args: GogArg[] = ['slides', 'copy', pos(presentationId), pos(title)];
|
|
62
67
|
if (parent) args.push(`--parent=${parent}`);
|
|
63
68
|
return runOrDiagnose(args, { account });
|
|
64
69
|
});
|
|
@@ -71,7 +76,7 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
71
76
|
account: accountParam,
|
|
72
77
|
}),
|
|
73
78
|
}, async ({ presentationId, account }) => {
|
|
74
|
-
return runOrDiagnose(['slides', 'list-slides', presentationId], { account });
|
|
79
|
+
return runOrDiagnose(['slides', 'list-slides', pos(presentationId)], { account });
|
|
75
80
|
});
|
|
76
81
|
|
|
77
82
|
server.registerTool('gog_slides_read_slide', {
|
|
@@ -84,10 +89,10 @@ export function registerSlidesTools(server: McpServer): void {
|
|
|
84
89
|
account: accountParam,
|
|
85
90
|
}),
|
|
86
91
|
}, async ({ presentationId, slideId, detail, account }) => {
|
|
87
|
-
const args = ['slides', 'read-slide', presentationId, slideId];
|
|
92
|
+
const args: GogArg[] = ['slides', 'read-slide', pos(presentationId), pos(slideId)];
|
|
88
93
|
if (detail) args.push('--detail');
|
|
89
94
|
return runOrDiagnose(args, { account });
|
|
90
95
|
});
|
|
91
96
|
|
|
92
|
-
registerRunTool(server, { service: 'slides', examples: '"
|
|
97
|
+
registerRunTool(server, { service: 'slides', examples: '"delete-slide", "update-notes"' });
|
|
93
98
|
}
|
package/src/tools/tasks.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
|
|
4
|
+
import { pos } from '../argv.js';
|
|
5
|
+
import type { GogArg } from '../runner.js';
|
|
4
6
|
|
|
5
7
|
export function registerTasksTools(server: McpServer): void {
|
|
6
8
|
server.registerTool('gog_tasks_lists', {
|
|
@@ -21,7 +23,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
21
23
|
account: accountParam,
|
|
22
24
|
}),
|
|
23
25
|
}, async ({ tasklistId, account }) => {
|
|
24
|
-
return runOrDiagnose(['tasks', 'list', tasklistId], { account });
|
|
26
|
+
return runOrDiagnose(['tasks', 'list', pos(tasklistId)], { account });
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
server.registerTool('gog_tasks_get', {
|
|
@@ -33,7 +35,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
33
35
|
account: accountParam,
|
|
34
36
|
}),
|
|
35
37
|
}, async ({ tasklistId, taskId, account }) => {
|
|
36
|
-
return runOrDiagnose(['tasks', 'get', tasklistId, taskId], { account });
|
|
38
|
+
return runOrDiagnose(['tasks', 'get', pos(tasklistId), pos(taskId)], { account });
|
|
37
39
|
});
|
|
38
40
|
|
|
39
41
|
server.registerTool('gog_tasks_add', {
|
|
@@ -47,7 +49,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
47
49
|
account: accountParam,
|
|
48
50
|
}),
|
|
49
51
|
}, async ({ tasklistId, title, notes, due, account }) => {
|
|
50
|
-
const args = ['tasks', 'add', tasklistId, `--title=${title}`];
|
|
52
|
+
const args: GogArg[] = ['tasks', 'add', pos(tasklistId), `--title=${title}`];
|
|
51
53
|
if (notes) args.push(`--notes=${notes}`);
|
|
52
54
|
if (due) args.push(`--due=${due}`);
|
|
53
55
|
return runOrDiagnose(args, { account });
|
|
@@ -62,7 +64,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
62
64
|
account: accountParam,
|
|
63
65
|
}),
|
|
64
66
|
}, async ({ tasklistId, taskId, account }) => {
|
|
65
|
-
return runOrDiagnose(['tasks', 'done', tasklistId, taskId], { account });
|
|
67
|
+
return runOrDiagnose(['tasks', 'done', pos(tasklistId), pos(taskId)], { account });
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
server.registerTool('gog_tasks_delete', {
|
|
@@ -76,7 +78,7 @@ export function registerTasksTools(server: McpServer): void {
|
|
|
76
78
|
}, async ({ tasklistId, taskId, account }) => {
|
|
77
79
|
// gog gates this delete behind a confirmation; the runner injects
|
|
78
80
|
// --no-input, so without --force it refuses at runtime.
|
|
79
|
-
return runOrDiagnose(['tasks', 'delete', tasklistId, taskId, '--force'], { account });
|
|
81
|
+
return runOrDiagnose(['tasks', 'delete', pos(tasklistId), pos(taskId), '--force'], { account });
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
registerRunTool(server, { service: 'tasks', examples: '"update", "undo", "clear"' });
|