gogcli-mcp-gmail 2.23.2 → 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/README.md +6 -3
- package/SKILL.md +4 -1
- package/dist/index.js +226 -45
- package/manifest.json +18 -6
- package/package.json +1 -1
- package/src/tools/gmail-extra.ts +137 -9
- package/tests/tools/gmail-extra.test.ts +296 -0
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> [!WARNING]
|
|
4
4
|
> **AI-developed project.** This codebase was built and is actively maintained by [Claude Code](https://www.anthropic.com/claude). Review all code and tool permissions before use.
|
|
5
5
|
|
|
6
|
-
Extended Gmail [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/openclaw/gogcli). Includes auth tools plus
|
|
6
|
+
Extended Gmail [MCP](https://modelcontextprotocol.io) server via [gogcli](https://github.com/openclaw/gogcli). Includes auth tools plus 49 additional dedicated Gmail tools for threads, labels, drafts, attachments, forwarding, autoreply, and bulk operations.
|
|
7
7
|
|
|
8
8
|
## Requirements
|
|
9
9
|
|
|
@@ -44,9 +44,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
|
44
44
|
claude mcp add gogcli-gmail -- gogcli-mcp-gmail
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
## Extra Gmail Tools (
|
|
47
|
+
## Extra Gmail Tools (49)
|
|
48
48
|
|
|
49
|
-
Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) —
|
|
49
|
+
Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) — 61 in all.
|
|
50
50
|
|
|
51
51
|
### Read
|
|
52
52
|
|
|
@@ -101,6 +101,9 @@ Plus 8 auth tools and 4 base Gmail tools (search, get, send, run) — 58 in all.
|
|
|
101
101
|
| `gog_gmail_drafts_delete` | Delete a draft |
|
|
102
102
|
| `gog_gmail_drafts_send` | Send an existing draft (a 404 comes back diagnosed — `DRAFT_FORKED`, or `GOOGLE_404_NOT_THE_DRAFT` when the draft is still listed) |
|
|
103
103
|
| `gog_gmail_drafts_diff` | Diff two named drafts — divergent body lines (with untruncated `onlyInACount`/`onlyInBCount`), threading loss, and a conservative fork verdict (2 gog calls) |
|
|
104
|
+
| `gog_gmail_drafts_reply` | Save a reply as a draft — inherited recipients, subject and quote; never sends |
|
|
105
|
+
| `gog_gmail_drafts_reply_all` | Save a reply-all as a draft; never sends |
|
|
106
|
+
| `gog_gmail_drafts_forward` | Save a forward as a draft; recipients optional, so it can be staged without any |
|
|
104
107
|
|
|
105
108
|
#### When a draft you created stops resolving
|
|
106
109
|
|
package/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Use when the user asks to read, organize, draft, forward, autoreply
|
|
|
5
5
|
|
|
6
6
|
# gogcli-mcp-gmail
|
|
7
7
|
|
|
8
|
-
Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) —
|
|
8
|
+
Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) — 61 tools: 8 auth + 4 base Gmail + 49 extra dedicated Gmail tools.
|
|
9
9
|
|
|
10
10
|
- **Source:** [github.com/chrischall/gogcli-mcp](https://github.com/chrischall/gogcli-mcp)
|
|
11
11
|
|
|
@@ -80,6 +80,9 @@ Extended Gmail MCP server via [gogcli](https://github.com/openclaw/gogcli) — 5
|
|
|
80
80
|
| `gog_gmail_drafts_delete` | Delete a draft |
|
|
81
81
|
| `gog_gmail_drafts_send` | Send a draft (404 → `DRAFT_FORKED`, or `GOOGLE_404_NOT_THE_DRAFT` if the draft is still listed) |
|
|
82
82
|
| `gog_gmail_drafts_diff` | Diff two drafts (body divergence, threading loss, fork verdict) |
|
|
83
|
+
| `gog_gmail_drafts_reply` | Save a reply as a draft — inherited recipients, subject and quote; never sends |
|
|
84
|
+
| `gog_gmail_drafts_reply_all` | Save a reply-all as a draft; never sends |
|
|
85
|
+
| `gog_gmail_drafts_forward` | Save a forward as a draft; recipients optional, so it can be staged without any |
|
|
83
86
|
|
|
84
87
|
A draft edited in a mail client is replaced, not updated: the old id 404s. `drafts_update` / `drafts_send` answer that
|
|
85
88
|
404 with a `DRAFT_FORKED` report (what happened, the drafts that exist, what to do) instead of a bare `notFound`, at a
|
package/dist/index.js
CHANGED
|
@@ -31182,10 +31182,11 @@ function sanitizedEnv() {
|
|
|
31182
31182
|
}
|
|
31183
31183
|
return result;
|
|
31184
31184
|
}
|
|
31185
|
+
var TOKEN_LEFT_BOUNDARY = "(?<![A-Za-z0-9+/])";
|
|
31185
31186
|
var GOOGLE_TOKEN_PATTERNS = [
|
|
31186
|
-
|
|
31187
|
+
new RegExp(`${TOKEN_LEFT_BOUNDARY}ya29\\.[A-Za-z0-9._\\-]+`, "g"),
|
|
31187
31188
|
// OAuth2 access tokens
|
|
31188
|
-
|
|
31189
|
+
new RegExp(`${TOKEN_LEFT_BOUNDARY}1//[A-Za-z0-9._\\-]+`, "g")
|
|
31189
31190
|
// OAuth2 refresh tokens
|
|
31190
31191
|
];
|
|
31191
31192
|
function redactGoogleTokens(text) {
|
|
@@ -31198,6 +31199,26 @@ function redactGoogleTokens(text) {
|
|
|
31198
31199
|
function redactSecrets2(text) {
|
|
31199
31200
|
return redactGoogleTokens(redactSecrets(text));
|
|
31200
31201
|
}
|
|
31202
|
+
var OPAQUE_FIELD_VALUE = "[A-Za-z0-9+/_-]{16,}={0,2}";
|
|
31203
|
+
var opaquePlaceholder = (i) => `\0gogOpaque${i}\0`;
|
|
31204
|
+
function redactPreservingOpaqueFields(text, fields, redact) {
|
|
31205
|
+
const lifted = [];
|
|
31206
|
+
let staged = text;
|
|
31207
|
+
for (const field of fields) {
|
|
31208
|
+
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31209
|
+
const re = new RegExp(`("${escaped}"\\s*:\\s*")(${OPAQUE_FIELD_VALUE})(")`, "g");
|
|
31210
|
+
staged = staged.replace(re, (_m, open, value, close) => {
|
|
31211
|
+
lifted.push(value);
|
|
31212
|
+
return `${open}${opaquePlaceholder(lifted.length - 1)}${close}`;
|
|
31213
|
+
});
|
|
31214
|
+
}
|
|
31215
|
+
if (lifted.length === 0) return redact(text);
|
|
31216
|
+
let redacted = redact(staged);
|
|
31217
|
+
lifted.forEach((value, i) => {
|
|
31218
|
+
redacted = redacted.split(opaquePlaceholder(i)).join(value);
|
|
31219
|
+
});
|
|
31220
|
+
return redacted;
|
|
31221
|
+
}
|
|
31201
31222
|
function augmentedPath() {
|
|
31202
31223
|
const home = process.env.HOME;
|
|
31203
31224
|
const candidates = [
|
|
@@ -31228,19 +31249,24 @@ function formatTimeout(ms) {
|
|
|
31228
31249
|
return `${ms}ms`;
|
|
31229
31250
|
}
|
|
31230
31251
|
async function spawnWithTempFiles(args, opts) {
|
|
31231
|
-
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
31252
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
|
|
31232
31253
|
const { tmpdir } = await import("node:os");
|
|
31233
31254
|
const dir = await mkdtemp(join(tmpdir(), "gogcli-mcp-"));
|
|
31234
31255
|
try {
|
|
31235
31256
|
const argv = [];
|
|
31257
|
+
let seq = 0;
|
|
31236
31258
|
for (const arg of args) {
|
|
31237
31259
|
if (!isGogFileArg(arg)) {
|
|
31238
31260
|
argv.push(arg);
|
|
31239
31261
|
continue;
|
|
31240
31262
|
}
|
|
31241
|
-
const
|
|
31242
|
-
|
|
31243
|
-
|
|
31263
|
+
const sub = join(dir, String(seq));
|
|
31264
|
+
seq += 1;
|
|
31265
|
+
await mkdir(sub, { recursive: true, mode: 448 });
|
|
31266
|
+
const path = join(sub, arg.filename ?? `${arg.flag}.${arg.ext ?? "txt"}`);
|
|
31267
|
+
const data = arg.encoding === "base64" ? Buffer.from(arg.contents, "base64") : Buffer.from(arg.contents, "utf8");
|
|
31268
|
+
await writeFile(path, data, { mode: 384 });
|
|
31269
|
+
argv.push(arg.positional ? path : `--${arg.flag}=${path}`);
|
|
31244
31270
|
}
|
|
31245
31271
|
return await spawnGog(argv, opts);
|
|
31246
31272
|
} finally {
|
|
@@ -31325,8 +31351,9 @@ function assembleArgs(args, opts) {
|
|
|
31325
31351
|
return fullArgs;
|
|
31326
31352
|
}
|
|
31327
31353
|
async function run(args, options = {}) {
|
|
31328
|
-
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
|
|
31329
|
-
const
|
|
31354
|
+
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full", opaqueFields } = options;
|
|
31355
|
+
const base = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
|
|
31356
|
+
const redact = opaqueFields?.length ? (text) => redactPreservingOpaqueFields(text, opaqueFields, base) : base;
|
|
31330
31357
|
const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
|
|
31331
31358
|
const store = activeExecutor();
|
|
31332
31359
|
try {
|
|
@@ -31340,7 +31367,7 @@ async function run(args, options = {}) {
|
|
|
31340
31367
|
}
|
|
31341
31368
|
return redact(output);
|
|
31342
31369
|
} catch (err) {
|
|
31343
|
-
const message =
|
|
31370
|
+
const message = base(err instanceof Error ? err.message : String(err));
|
|
31344
31371
|
if (isRunnerTransportError(err)) {
|
|
31345
31372
|
throw new RunnerTransportError(message, err.kind, err.status);
|
|
31346
31373
|
}
|
|
@@ -31753,6 +31780,7 @@ function formatAuthHealth(raw, now) {
|
|
|
31753
31780
|
// ../gogcli-mcp/src/tools/auth.ts
|
|
31754
31781
|
function registerAuthToolsWith(server, defaultServices) {
|
|
31755
31782
|
const servicesDescribe = `Services to authorize: "all" or comma-separated list (e.g. "sheets,gmail,calendar"). Default: "${defaultServices}". Prefer the narrowest set you need \u2014 requesting a service whose Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request with invalid_scope.`;
|
|
31783
|
+
const extraScopesDescribe = "Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. Use for scopes no service covers \u2014 e.g. https://www.googleapis.com/auth/bigquery.readonly, required before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE authorization with invalid_scope.";
|
|
31756
31784
|
server.registerTool("gog_auth_list", {
|
|
31757
31785
|
description: "List the Google accounts stored in gogcli, with their scopes. This reads local configuration only \u2014 it does not contact Google and does NOT tell you whether an account still works: a signed-out account whose refresh token expired or was revoked is listed here exactly like a healthy one, scopes and all. Use gog_auth_health to check whether an account can actually authenticate.",
|
|
31758
31786
|
annotations: { readOnlyHint: true },
|
|
@@ -31802,11 +31830,14 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31802
31830
|
annotations: { destructiveHint: true },
|
|
31803
31831
|
inputSchema: {
|
|
31804
31832
|
email: external_exports.string().describe("Google account email to authorize"),
|
|
31805
|
-
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
|
|
31833
|
+
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
31834
|
+
extraScopes: external_exports.string().optional().describe(extraScopesDescribe)
|
|
31806
31835
|
}
|
|
31807
|
-
}, async ({ email: email3, services = defaultServices }) => {
|
|
31836
|
+
}, async ({ email: email3, services = defaultServices, extraScopes }) => {
|
|
31808
31837
|
try {
|
|
31809
|
-
|
|
31838
|
+
const args = ["auth", "add", email3, "--services", services];
|
|
31839
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, "--force-consent");
|
|
31840
|
+
return rawTextResult(await run(args, {
|
|
31810
31841
|
interactive: true,
|
|
31811
31842
|
timeout: 3e5
|
|
31812
31843
|
}));
|
|
@@ -31818,14 +31849,14 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31818
31849
|
description: "Begin REMOTE/headless Google authorization (step 1 of 2). Returns a sign-in URL to open in any browser \u2014 no local server or terminal on the gogcli host is needed, so this works over the hosted connector where the interactive gog_auth_add cannot. Hand the URL to the user; after they sign in, the browser is redirected to a localhost URL that fails to load \u2014 that is expected. They copy that full redirected URL (from the address bar) and you pass it to gog_auth_add_complete. The link is valid for 10 minutes. If you pass a custom `services` here, pass the SAME value to gog_auth_add_complete or the second step will not match this one.",
|
|
31819
31850
|
inputSchema: {
|
|
31820
31851
|
email: external_exports.string().describe("Google account email to authorize"),
|
|
31821
|
-
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe)
|
|
31852
|
+
services: external_exports.string().optional().default(defaultServices).describe(servicesDescribe),
|
|
31853
|
+
extraScopes: external_exports.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`)
|
|
31822
31854
|
}
|
|
31823
|
-
}, async ({ email: email3, services = defaultServices }) => {
|
|
31855
|
+
}, async ({ email: email3, services = defaultServices, extraScopes }) => {
|
|
31824
31856
|
try {
|
|
31825
|
-
|
|
31826
|
-
|
|
31827
|
-
|
|
31828
|
-
));
|
|
31857
|
+
const args = ["auth", "add", email3, "--remote", "--step", "1", "--services", services, "--force-consent"];
|
|
31858
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
31859
|
+
return rawTextResult(await run(args, { redactMode: "tokens" }));
|
|
31829
31860
|
} catch (err) {
|
|
31830
31861
|
return errorResult(errorText(err));
|
|
31831
31862
|
}
|
|
@@ -31840,25 +31871,28 @@ function registerAuthToolsWith(server, defaultServices) {
|
|
|
31840
31871
|
),
|
|
31841
31872
|
services: external_exports.string().optional().default(defaultServices).describe(
|
|
31842
31873
|
`Services authorized \u2014 MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`
|
|
31874
|
+
),
|
|
31875
|
+
extraScopes: external_exports.string().optional().describe(
|
|
31876
|
+
"Extra OAuth scope URIs \u2014 MUST match the value passed to gog_auth_add_url, for the same reason `services` must: the two steps have to describe the same grant."
|
|
31843
31877
|
)
|
|
31844
31878
|
}
|
|
31845
|
-
}, async ({ email: email3, redirectUrl, services = defaultServices }) => {
|
|
31879
|
+
}, async ({ email: email3, redirectUrl, services = defaultServices, extraScopes }) => {
|
|
31846
31880
|
try {
|
|
31847
|
-
|
|
31848
|
-
|
|
31849
|
-
|
|
31850
|
-
|
|
31851
|
-
|
|
31852
|
-
|
|
31853
|
-
|
|
31854
|
-
|
|
31855
|
-
|
|
31856
|
-
|
|
31857
|
-
|
|
31858
|
-
|
|
31859
|
-
|
|
31860
|
-
|
|
31861
|
-
));
|
|
31881
|
+
const args = [
|
|
31882
|
+
"auth",
|
|
31883
|
+
"add",
|
|
31884
|
+
email3,
|
|
31885
|
+
"--remote",
|
|
31886
|
+
"--step",
|
|
31887
|
+
"2",
|
|
31888
|
+
"--auth-url",
|
|
31889
|
+
redirectUrl,
|
|
31890
|
+
"--services",
|
|
31891
|
+
services,
|
|
31892
|
+
"--force-consent"
|
|
31893
|
+
];
|
|
31894
|
+
if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
|
|
31895
|
+
return rawTextResult(await run(args));
|
|
31862
31896
|
} catch (err) {
|
|
31863
31897
|
return errorResult(errorText(err));
|
|
31864
31898
|
}
|
|
@@ -31966,6 +32000,85 @@ function finish(base, itemsKey, merged, token) {
|
|
|
31966
32000
|
return rawTextResult(JSON.stringify(out));
|
|
31967
32001
|
}
|
|
31968
32002
|
|
|
32003
|
+
// ../gogcli-mcp/src/attachments.ts
|
|
32004
|
+
var MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
32005
|
+
var RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
32006
|
+
var RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
|
|
32007
|
+
var MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
|
|
32008
|
+
var MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor(MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3 / 4);
|
|
32009
|
+
function wireBytesOf(arg) {
|
|
32010
|
+
if (typeof arg === "string") return Buffer.byteLength(arg, "utf8");
|
|
32011
|
+
return arg.encoding === "base64" ? arg.contents.length : Buffer.byteLength(arg.contents, "utf8");
|
|
32012
|
+
}
|
|
32013
|
+
var formatMiB = (bytes) => `${Math.floor(bytes / (1024 * 1024))} MiB`;
|
|
32014
|
+
var INLINE_ATTACHMENT_LIMITS_TEXT = `up to ${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)} per file and ${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)} in total`;
|
|
32015
|
+
var inlineAttachmentSchema = external_exports.object({
|
|
32016
|
+
filename: external_exports.string().min(1).describe(
|
|
32017
|
+
`Filename the recipient will see, e.g. "pendant-layouts.png". gog infers the attachment's MIME type from this extension, so give it the right one \u2014 a .png sent as "layouts" arrives as an untyped blob. Must be a single filename, not a path.`
|
|
32018
|
+
),
|
|
32019
|
+
contentBase64: external_exports.string().min(1).describe(
|
|
32020
|
+
"The file's bytes, base64-encoded (standard alphabet, with padding). This is the whole point of this parameter: the bytes travel with the request, so nothing needs to exist on the gog server's filesystem."
|
|
32021
|
+
)
|
|
32022
|
+
});
|
|
32023
|
+
var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional().describe(
|
|
32024
|
+
`Attachments supplied as BYTES rather than as server-side paths \u2014 use this whenever you hold a file and the gog server does not, which is always the case on the hosted connector and on any remote deployment. Each entry is {filename, contentBase64} (${INLINE_ATTACHMENT_LIMITS_TEXT}). Can be combined with \`attach\`: the two name disjoint files (paths read on the server vs. bytes sent with the call), and both end up as ordinary attachments on the message.`
|
|
32025
|
+
);
|
|
32026
|
+
function validateFilename(filename, where) {
|
|
32027
|
+
if (/[/\\]/.test(filename)) {
|
|
32028
|
+
throw new Error(
|
|
32029
|
+
`${where}: filename ${JSON.stringify(filename)} must be a bare filename, not a path. Pass just the name the recipient should see, e.g. "report.pdf".`
|
|
32030
|
+
);
|
|
32031
|
+
}
|
|
32032
|
+
if (/[\x00-\x1f]/.test(filename) || /^\.+$/.test(filename) || filename.length > 200) {
|
|
32033
|
+
throw new Error(
|
|
32034
|
+
`${where}: filename ${JSON.stringify(filename)} is not a usable filename (no control characters, not "."/"..", 200 characters max).`
|
|
32035
|
+
);
|
|
32036
|
+
}
|
|
32037
|
+
}
|
|
32038
|
+
function decodedLength(contentBase64) {
|
|
32039
|
+
const buf = Buffer.from(contentBase64, "base64");
|
|
32040
|
+
return buf.toString("base64") === contentBase64 ? buf.length : null;
|
|
32041
|
+
}
|
|
32042
|
+
function inlineFileArg(flag, attachment, opts = {}) {
|
|
32043
|
+
const { filename, contentBase64 } = attachment;
|
|
32044
|
+
const where = opts.where ?? `attachInline entry ${JSON.stringify(filename)}`;
|
|
32045
|
+
validateFilename(filename, where);
|
|
32046
|
+
const bytes = decodedLength(contentBase64);
|
|
32047
|
+
if (bytes === null) {
|
|
32048
|
+
throw new Error(
|
|
32049
|
+
`${where}: contents are not valid base64. Send the standard alphabet with padding and no line breaks \u2014 the value must survive a decode/re-encode round trip unchanged.`
|
|
32050
|
+
);
|
|
32051
|
+
}
|
|
32052
|
+
if (bytes > MAX_INLINE_ATTACHMENT_BYTES) {
|
|
32053
|
+
throw new Error(
|
|
32054
|
+
`${where}: ${bytes} bytes exceeds the ${MAX_INLINE_ATTACHMENT_BYTES}-byte (${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)}) per-file limit for inline content. Upload it to Drive and link to it instead, or send it from a local (stdio) deployment using a real server-side path.`
|
|
32055
|
+
);
|
|
32056
|
+
}
|
|
32057
|
+
const arg = { kind: "file", flag, contents: contentBase64, encoding: "base64", filename };
|
|
32058
|
+
if (opts.positional) arg.positional = true;
|
|
32059
|
+
return { arg, bytes };
|
|
32060
|
+
}
|
|
32061
|
+
function inlineAttachmentArgs(flag, attachments, siblingArgs = []) {
|
|
32062
|
+
if (!attachments?.length) return [];
|
|
32063
|
+
const args = [];
|
|
32064
|
+
const siblingWire = siblingArgs.reduce((sum, arg) => sum + wireBytesOf(arg), 0);
|
|
32065
|
+
let attachmentWire = 0;
|
|
32066
|
+
let decodedTotal = 0;
|
|
32067
|
+
for (const attachment of attachments) {
|
|
32068
|
+
const { arg, bytes } = inlineFileArg(flag, attachment);
|
|
32069
|
+
attachmentWire += arg.contents.length;
|
|
32070
|
+
decodedTotal += bytes;
|
|
32071
|
+
if (siblingWire + attachmentWire > MAX_REQUEST_PAYLOAD_WIRE_BYTES) {
|
|
32072
|
+
const blame = attachmentWire <= MAX_REQUEST_PAYLOAD_WIRE_BYTES ? ` These attachments would fit on their own; the rest of the message (its body, mostly) spends ${siblingWire} bytes of the same budget.` : "";
|
|
32073
|
+
throw new Error(
|
|
32074
|
+
`This message is too large to send: ${decodedTotal} bytes of attachments (${attachmentWire} bytes once base64-encoded for transit) exceed the ${MAX_REQUEST_PAYLOAD_WIRE_BYTES}-byte request limit.${blame} The ceiling for attachments alone is ${MAX_INLINE_ATTACHMENT_TOTAL_BYTES} bytes (${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)}); a long body lowers it. Send fewer or smaller files per message, shorten the body, or upload the large files to Drive and link them.`
|
|
32075
|
+
);
|
|
32076
|
+
}
|
|
32077
|
+
args.push(arg);
|
|
32078
|
+
}
|
|
32079
|
+
return args;
|
|
32080
|
+
}
|
|
32081
|
+
|
|
31969
32082
|
// ../gogcli-mcp/src/tools/gmail.ts
|
|
31970
32083
|
function registerGmailTools(server) {
|
|
31971
32084
|
server.registerTool("gog_gmail_search", {
|
|
@@ -32000,20 +32113,26 @@ function registerGmailTools(server) {
|
|
|
32000
32113
|
});
|
|
32001
32114
|
});
|
|
32002
32115
|
server.registerTool("gog_gmail_get", {
|
|
32003
|
-
description: "Get a Gmail message by ID.",
|
|
32116
|
+
description: "Get a Gmail message by ID. For a long message, sanitizeContent is the cheapest way to keep it in context: it drops the raw MIME payload and the HTML part, which are usually the bulk of the response.",
|
|
32004
32117
|
annotations: { readOnlyHint: true },
|
|
32005
32118
|
inputSchema: {
|
|
32006
32119
|
messageId: external_exports.string().describe("Message ID"),
|
|
32007
32120
|
format: external_exports.enum(["full", "metadata", "raw"]).optional().describe("Message format (default: full)"),
|
|
32121
|
+
// Requires gog >= 0.37.0. Before that (openclaw/gogcli#992) the JSON
|
|
32122
|
+
// carried the headers and body TWICE — once inside `message`, once
|
|
32123
|
+
// copied to the top level — so the flag meant to shrink the payload
|
|
32124
|
+
// enlarged it. MIN_GOG_VERSION is the guard; there is no runtime check.
|
|
32125
|
+
sanitizeContent: external_exports.boolean().optional().describe("Return agent-oriented sanitized content: HTML stripped, HTTP(S) URLs removed, raw Gmail payloads omitted from the JSON. The largest payload-size reduction available here. Note the URL removal is lossy \u2014 omit this when you need to follow a link out of the message."),
|
|
32008
32126
|
account: accountParam
|
|
32009
32127
|
}
|
|
32010
|
-
}, async ({ messageId, format, account }) => {
|
|
32128
|
+
}, async ({ messageId, format, sanitizeContent, account }) => {
|
|
32011
32129
|
const args = ["gmail", "get", messageId];
|
|
32012
32130
|
if (format) args.push(`--format=${format}`);
|
|
32131
|
+
if (sanitizeContent) args.push("--sanitize-content");
|
|
32013
32132
|
return runOrDiagnose(args, { account });
|
|
32014
32133
|
});
|
|
32015
32134
|
server.registerTool("gog_gmail_send", {
|
|
32016
|
-
description:
|
|
32135
|
+
description: 'Send an email. Two ways to attach a file: `attach` takes paths READ ON THE GOG SERVER, and `attachInline` takes the bytes themselves. Use attachInline unless you know the file exists on the same machine gog runs on \u2014 on the hosted connector and any remote deployment there is no shared filesystem, so no path you can name resolves there and `attach` will fail with "no such file or directory". When either is used, the JSON result echoes the attached filenames and byte sizes \u2014 check it to confirm the files were embedded.',
|
|
32017
32136
|
annotations: { destructiveHint: true },
|
|
32018
32137
|
inputSchema: {
|
|
32019
32138
|
to: external_exports.string().describe("Recipient(s), comma-separated"),
|
|
@@ -32023,16 +32142,19 @@ function registerGmailTools(server) {
|
|
|
32023
32142
|
bcc: external_exports.string().optional().describe("BCC recipients, comma-separated"),
|
|
32024
32143
|
replyToMessageId: external_exports.string().optional().describe("Message ID to reply to"),
|
|
32025
32144
|
threadId: external_exports.string().optional().describe("Thread ID to reply within"),
|
|
32026
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
32145
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 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" \u2014 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.`),
|
|
32146
|
+
attachInline: attachInlineParam,
|
|
32027
32147
|
account: accountParam
|
|
32028
32148
|
}
|
|
32029
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, account }) => {
|
|
32149
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
|
|
32030
32150
|
const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
|
|
32031
32151
|
if (cc) args.push(`--cc=${cc}`);
|
|
32032
32152
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
32033
32153
|
if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
|
|
32034
32154
|
if (threadId) args.push(`--thread-id=${threadId}`);
|
|
32035
32155
|
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
32156
|
+
const inline = inlineAttachmentArgs("attach", attachInline, args);
|
|
32157
|
+
args.push(...inline);
|
|
32036
32158
|
return runOrDiagnose(args, { account });
|
|
32037
32159
|
});
|
|
32038
32160
|
registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
|
|
@@ -32048,7 +32170,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
|
32048
32170
|
);
|
|
32049
32171
|
|
|
32050
32172
|
// ../gogcli-mcp/src/server.ts
|
|
32051
|
-
var VERSION = true ? "2.
|
|
32173
|
+
var VERSION = true ? "2.25.0" : "0.0.0";
|
|
32052
32174
|
|
|
32053
32175
|
// ../gogcli-mcp/src/auth-log.ts
|
|
32054
32176
|
var FAILURES = /* @__PURE__ */ new Set([
|
|
@@ -32622,6 +32744,9 @@ var MAGIC_SIGNATURES = [
|
|
|
32622
32744
|
["\xFF\xD8\xFF", "image/jpeg"],
|
|
32623
32745
|
["GIF8", "image/gif"]
|
|
32624
32746
|
];
|
|
32747
|
+
function isValidBase642(value) {
|
|
32748
|
+
return Buffer.from(value, "base64").toString("base64") === value;
|
|
32749
|
+
}
|
|
32625
32750
|
function sniffMime(base643) {
|
|
32626
32751
|
const head = atob(base643.slice(0, 16));
|
|
32627
32752
|
for (const [signature, mimeType] of MAGIC_SIGNATURES) {
|
|
@@ -33558,8 +33683,15 @@ function registerExtraGmailTools(server) {
|
|
|
33558
33683
|
if (needInline) args.push("--inline");
|
|
33559
33684
|
args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
|
|
33560
33685
|
args.push(`--out=${outPath}`, `--name=${filename ?? "attachment"}`);
|
|
33561
|
-
const info = JSON.parse(await run(args, { account }));
|
|
33686
|
+
const info = JSON.parse(await run(args, { account, opaqueFields: ["contentBase64"] }));
|
|
33562
33687
|
const path = info.path ?? outPath;
|
|
33688
|
+
const inlineUnusable = info.contentBase64 !== void 0 && !isValidBase642(info.contentBase64);
|
|
33689
|
+
if (inlineUnusable) {
|
|
33690
|
+
delete info.contentBase64;
|
|
33691
|
+
notes.push(
|
|
33692
|
+
"The inline copy of this attachment was dropped: the bytes returned by the server were not valid base64, so returning them would have failed as a protocol error. The file itself was downloaded successfully and is delivered below."
|
|
33693
|
+
);
|
|
33694
|
+
}
|
|
33563
33695
|
if (!filename && info.filename) filename = sanitizeFilename(info.filename);
|
|
33564
33696
|
if (!mimeType && info.mimeType) mimeType = info.mimeType;
|
|
33565
33697
|
if (!filename && !indexed) {
|
|
@@ -33585,6 +33717,11 @@ function registerExtraGmailTools(server) {
|
|
|
33585
33717
|
if (info.contentBase64) {
|
|
33586
33718
|
return withNote(isImage ? inlineImageResult(summary, info.contentBase64, mimeType) : inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
|
|
33587
33719
|
}
|
|
33720
|
+
if (inlineUnusable) {
|
|
33721
|
+
return errorResult(
|
|
33722
|
+
`The bytes returned for ${filename} were not valid base64, so they cannot be delivered inline (the MCP transport would reject them as a protocol error). The file WAS downloaded and is readable server-side at ${path}. Use deliver="auto" or deliver="drive" to receive it.`
|
|
33723
|
+
);
|
|
33724
|
+
}
|
|
33588
33725
|
return errorResult(
|
|
33589
33726
|
`Attachment is too large to return inline (${info.reason ?? "exceeds gog's inline size limit, 3 MiB by default \u2014 raise inlineMaxBytes"}). Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.`
|
|
33590
33727
|
);
|
|
@@ -34004,7 +34141,8 @@ function registerExtraGmailTools(server) {
|
|
|
34004
34141
|
replyTo: external_exports.string().optional().describe("Reply-To header address"),
|
|
34005
34142
|
quote: external_exports.boolean().optional().describe("Include quoted original message in reply (requires replyToMessageId or replyToThreadId)"),
|
|
34006
34143
|
replyAll: external_exports.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."),
|
|
34007
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
34144
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 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" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension. The JSON result echoes attached filenames and byte sizes \u2014 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).`),
|
|
34145
|
+
attachInline: attachInlineParam,
|
|
34008
34146
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34009
34147
|
autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
|
|
34010
34148
|
omitRecipients: external_exports.boolean().optional().describe("Create the draft with no recipients even if to/cc/bcc are supplied \u2014 an accidental-send guard. Populate recipients in a later update before sending."),
|
|
@@ -34028,6 +34166,7 @@ function registerExtraGmailTools(server) {
|
|
|
34028
34166
|
if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
|
|
34029
34167
|
if (f.quote) args.push("--quote");
|
|
34030
34168
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
34169
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
34031
34170
|
if (f.from) args.push(`--from=${f.from}`);
|
|
34032
34171
|
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
34033
34172
|
}
|
|
@@ -34199,7 +34338,8 @@ function registerExtraGmailTools(server) {
|
|
|
34199
34338
|
remove: external_exports.array(external_exports.string()).optional().describe("Remove these recipients from all fields (repeatable) \u2014 e.g. to drop someone from a reply-all."),
|
|
34200
34339
|
subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
34201
34340
|
noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
|
|
34202
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
34341
|
+
attach: external_exports.array(external_exports.string()).optional().describe(`File paths to attach (repeatable), resolved ON THE GOG SERVER's filesystem \u2014 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" \u2014 use attachInline there. Read on the server, base64-encoded with a MIME type inferred from the extension.`),
|
|
34342
|
+
attachInline: attachInlineParam,
|
|
34203
34343
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34204
34344
|
autoFromAddressedAlias: external_exports.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 \u2014 so a reply to mail sent to an alias goes back out from that alias. Ignored when from is set."),
|
|
34205
34345
|
signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
|
|
@@ -34219,6 +34359,7 @@ function registerExtraGmailTools(server) {
|
|
|
34219
34359
|
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
34220
34360
|
if (f.noQuote) args.push("--no-quote");
|
|
34221
34361
|
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
34362
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
34222
34363
|
if (f.from) args.push(`--from=${f.from}`);
|
|
34223
34364
|
if (f.signature) args.push("--signature");
|
|
34224
34365
|
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
@@ -34226,7 +34367,7 @@ function registerExtraGmailTools(server) {
|
|
|
34226
34367
|
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
34227
34368
|
}
|
|
34228
34369
|
server.registerTool("gog_gmail_reply", {
|
|
34229
|
-
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
|
|
34370
|
+
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 this same reply without sending it use gog_gmail_drafts_reply, which composes exactly what this tool would send.',
|
|
34230
34371
|
annotations: { destructiveHint: true },
|
|
34231
34372
|
inputSchema: replySchema
|
|
34232
34373
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -34235,7 +34376,7 @@ function registerExtraGmailTools(server) {
|
|
|
34235
34376
|
return runOrDiagnose(args, { account });
|
|
34236
34377
|
});
|
|
34237
34378
|
server.registerTool("gog_gmail_reply_all", {
|
|
34238
|
-
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.',
|
|
34379
|
+
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. To stage it without sending use gog_gmail_drafts_reply_all.',
|
|
34239
34380
|
annotations: { destructiveHint: true },
|
|
34240
34381
|
inputSchema: replySchema
|
|
34241
34382
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -34243,6 +34384,46 @@ function registerExtraGmailTools(server) {
|
|
|
34243
34384
|
appendReplyFlags(args, flags);
|
|
34244
34385
|
return runOrDiagnose(args, { account });
|
|
34245
34386
|
});
|
|
34387
|
+
const draftReplyNote = ' Composes exactly what gog_gmail_reply%s would send \u2014 inherited recipients, "Re:" subject and quoted original \u2014 but SAVES IT AS A DRAFT instead of sending. Nothing leaves the mailbox; send it later with gog_gmail_drafts_send, or edit it first with gog_gmail_drafts_update (which overwrites the whole body, quote included \u2014 read the draft back before editing).';
|
|
34388
|
+
server.registerTool("gog_gmail_drafts_reply", {
|
|
34389
|
+
description: "Save a reply to a Gmail message as a draft (to the original sender only)." + draftReplyNote.replace("%s", "") + " Prefer this over gog_gmail_drafts_create + replyToMessageId when the draft is a real reply: that route threads the draft but leaves recipients and quoting for you to reconstruct.",
|
|
34390
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull }
|
|
34391
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
34392
|
+
const args = ["gmail", "drafts", "reply", messageId];
|
|
34393
|
+
appendReplyFlags(args, flags);
|
|
34394
|
+
return writeDraft(args, account, returnFull);
|
|
34395
|
+
});
|
|
34396
|
+
server.registerTool("gog_gmail_drafts_reply_all", {
|
|
34397
|
+
description: "Save a reply-all to a Gmail message as a draft (sender plus every To/Cc recipient)." + draftReplyNote.replace("%s", "_all") + " Use the remove flag to drop recipients BEFORE the draft exists, rather than editing them out afterwards.",
|
|
34398
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull }
|
|
34399
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
34400
|
+
const args = ["gmail", "drafts", "reply-all", messageId];
|
|
34401
|
+
appendReplyFlags(args, flags);
|
|
34402
|
+
return writeDraft(args, account, returnFull);
|
|
34403
|
+
});
|
|
34404
|
+
server.registerTool("gog_gmail_drafts_forward", {
|
|
34405
|
+
description: "Save a forward of a Gmail message as a draft. Same composition as gog_gmail_forward \u2014 the original message quoted below an optional note, with its attachments carried over \u2014 but nothing is sent. Unlike gog_gmail_forward, `to` is OPTIONAL here: omit it to stage a recipient-less forward as an accidental-send guard, then add recipients with gog_gmail_drafts_update before gog_gmail_drafts_send.",
|
|
34406
|
+
inputSchema: {
|
|
34407
|
+
messageId: external_exports.string().describe("Gmail message ID to forward"),
|
|
34408
|
+
to: external_exports.string().optional().describe("Recipients (comma-separated). Optional for a draft \u2014 omit to stage the forward without recipients."),
|
|
34409
|
+
cc: external_exports.string().optional().describe("CC recipients (comma-separated)"),
|
|
34410
|
+
bcc: external_exports.string().optional().describe("BCC recipients (comma-separated)"),
|
|
34411
|
+
note: external_exports.string().optional().describe("Introductory text above the forwarded message"),
|
|
34412
|
+
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34413
|
+
skipAttachments: external_exports.boolean().optional().describe("Do not include original attachments"),
|
|
34414
|
+
returnFull: draftWriteSchema.returnFull,
|
|
34415
|
+
account: accountParam
|
|
34416
|
+
}
|
|
34417
|
+
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, returnFull, account }) => {
|
|
34418
|
+
const args = ["gmail", "drafts", "forward", messageId];
|
|
34419
|
+
if (to) args.push(`--to=${to}`);
|
|
34420
|
+
if (cc) args.push(`--cc=${cc}`);
|
|
34421
|
+
if (bcc) args.push(`--bcc=${bcc}`);
|
|
34422
|
+
if (note) args.push(payloadArg("note", "note-file", note));
|
|
34423
|
+
if (from) args.push(`--from=${from}`);
|
|
34424
|
+
if (skipAttachments) args.push("--skip-attachments");
|
|
34425
|
+
return writeDraft(args, account, returnFull);
|
|
34426
|
+
});
|
|
34246
34427
|
server.registerTool("gog_gmail_autoreply", {
|
|
34247
34428
|
description: "Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.",
|
|
34248
34429
|
annotations: { destructiveHint: true },
|
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "gogcli-mcp-gmail",
|
|
5
5
|
"display_name": "gogcli (Gmail)",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.25.0",
|
|
7
7
|
"description": "Extended Gmail for Claude via gogcli — auth + full Gmail support (threads, labels, drafts, attachments, forward, autoreply, bulk operations)",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Hall",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
{
|
|
101
101
|
"name": "gog_gmail_send",
|
|
102
|
-
"description": "Send an email"
|
|
102
|
+
"description": "Send an email, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
|
|
103
103
|
},
|
|
104
104
|
{
|
|
105
105
|
"name": "gog_gmail_run",
|
|
@@ -195,11 +195,11 @@
|
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
197
|
"name": "gog_gmail_drafts_create",
|
|
198
|
-
"description": "Create a new Gmail draft"
|
|
198
|
+
"description": "Create a new Gmail draft, with attachments from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)"
|
|
199
199
|
},
|
|
200
200
|
{
|
|
201
201
|
"name": "gog_gmail_drafts_update",
|
|
202
|
-
"description": "Update an existing Gmail draft; re-thread it in place with replyToThreadId (same draft id) and verify the result via threadingVerification; forkSiblingDraftId refuses the write when the new body would drop text the named sibling copy still holds; a 404 is attributed to the draft id or to the reply target before it is reported"
|
|
202
|
+
"description": "Update an existing Gmail draft; re-thread it in place with replyToThreadId (same draft id) and verify the result via threadingVerification; forkSiblingDraftId refuses the write when the new body would drop text the named sibling copy still holds; a 404 is attributed to the draft id or to the reply target before it is reported Attachments come from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)."
|
|
203
203
|
},
|
|
204
204
|
{
|
|
205
205
|
"name": "gog_gmail_drafts_delete",
|
|
@@ -213,6 +213,18 @@
|
|
|
213
213
|
"name": "gog_gmail_drafts_diff",
|
|
214
214
|
"description": "Diff two named drafts: what each body has that the other lost, how their threading differs, and (only on a link back to the original or on agreement over unquoted text, with evidence) whether one replaced the other"
|
|
215
215
|
},
|
|
216
|
+
{
|
|
217
|
+
"name": "gog_gmail_drafts_reply",
|
|
218
|
+
"description": "Save a reply to a Gmail message as a draft (inherited recipients, subject and quote; never sends) Attachments come from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)."
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "gog_gmail_drafts_reply_all",
|
|
222
|
+
"description": "Save a reply-all to a Gmail message as a draft (never sends) Attachments come from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)."
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
"name": "gog_gmail_drafts_forward",
|
|
226
|
+
"description": "Save a forward of a Gmail message as a draft; recipients are optional (never sends)"
|
|
227
|
+
},
|
|
216
228
|
{
|
|
217
229
|
"name": "gog_gmail_import",
|
|
218
230
|
"description": "Import an RFC822/EML message into the mailbox (keeps its original headers and date; does not send)"
|
|
@@ -223,11 +235,11 @@
|
|
|
223
235
|
},
|
|
224
236
|
{
|
|
225
237
|
"name": "gog_gmail_reply",
|
|
226
|
-
"description": "Reply to a Gmail message (sender only)"
|
|
238
|
+
"description": "Reply to a Gmail message (sender only) Attachments come from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)."
|
|
227
239
|
},
|
|
228
240
|
{
|
|
229
241
|
"name": "gog_gmail_reply_all",
|
|
230
|
-
"description": "Reply to all participants of a Gmail message"
|
|
242
|
+
"description": "Reply to all participants of a Gmail message Attachments come from server-side paths (attach) or from base64 bytes sent with the call (attachInline, for remote deployments with no shared filesystem)."
|
|
231
243
|
},
|
|
232
244
|
{
|
|
233
245
|
"name": "gog_gmail_autoreply",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.25.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/gogcli-mcp-gmail",
|
|
5
5
|
"description": "Extended Gmail MCP server via gogcli — auth + full Gmail support (threads, labels, drafts, attachments, forward, autoreply, bulk operations)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/src/tools/gmail-extra.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
4
4
|
import { rawTextResult, textResult, errorResult } from '@chrischall/mcp-utils';
|
|
5
|
-
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps, finalizeGmailSearch, fetchGmailPages, pageTokenParam, pageAliasParam, resolvePageToken} from '../../../gogcli-mcp/src/lib.js';
|
|
6
|
-
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
5
|
+
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps, finalizeGmailSearch, fetchGmailPages, pageTokenParam, pageAliasParam, resolvePageToken, attachInlineParam, inlineAttachmentArgs} from '../../../gogcli-mcp/src/lib.js';
|
|
6
|
+
import type { GogArg, InlineAttachmentInput } from '../../../gogcli-mcp/src/lib.js';
|
|
7
7
|
|
|
8
8
|
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
9
9
|
// create` errors with "use only one of --body-html or --body-html-file", and
|
|
@@ -191,9 +191,29 @@ const MAGIC_SIGNATURES: ReadonlyArray<readonly [string, string]> = [
|
|
|
191
191
|
['GIF8', 'image/gif'],
|
|
192
192
|
];
|
|
193
193
|
|
|
194
|
+
// Does this string survive a base64 decode/re-encode round trip unchanged?
|
|
195
|
+
//
|
|
196
|
+
// The MCP SDK validates an image block's `data` and a resource block's `blob`
|
|
197
|
+
// against its own base64 schema, and a failure there is a PROTOCOL error
|
|
198
|
+
// (-32602 "Invalid Base64 string") — thrown past this tool's try/catch, so the
|
|
199
|
+
// caller gets a wire-level fault with no clue which attachment caused it and no
|
|
200
|
+
// suggestion of what to do instead. Checking here converts that into an ordinary
|
|
201
|
+
// tool result that can name the file and offer a working alternative.
|
|
202
|
+
//
|
|
203
|
+
// No try/catch: `Buffer.from(…, 'base64')` is total — it SKIPS characters it
|
|
204
|
+
// does not recognise rather than throwing, which is precisely why a bare decode
|
|
205
|
+
// cannot be used as the check and the re-encode comparison is required.
|
|
206
|
+
function isValidBase64(value: string): boolean {
|
|
207
|
+
return Buffer.from(value, 'base64').toString('base64') === value;
|
|
208
|
+
}
|
|
209
|
+
|
|
194
210
|
// Sniff a MIME type from the leading bytes of standard base64; returns undefined
|
|
195
|
-
// for anything unrecognised.
|
|
196
|
-
//
|
|
211
|
+
// for anything unrecognised.
|
|
212
|
+
//
|
|
213
|
+
// `atob` cannot throw here, and that is now ENFORCED rather than assumed: the
|
|
214
|
+
// caller drops `contentBase64` outright when isValidBase64 rejects it, so this
|
|
215
|
+
// only ever runs on a payload that round-trips — and any 4-aligned prefix of
|
|
216
|
+
// valid base64 is itself valid.
|
|
197
217
|
function sniffMime(base64: string): string | undefined {
|
|
198
218
|
const head = atob(base64.slice(0, 16)); // 4-aligned slice; decodes to ~12 bytes
|
|
199
219
|
for (const [signature, mimeType] of MAGIC_SIGNATURES) {
|
|
@@ -2288,9 +2308,29 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2288
2308
|
// default keeps the arg array the single authority on both transports.
|
|
2289
2309
|
args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
|
|
2290
2310
|
args.push(`--out=${outPath}`, `--name=${filename ?? 'attachment'}`);
|
|
2291
|
-
|
|
2311
|
+
// `contentBase64` is exempt from redaction: it is the attachment's own
|
|
2312
|
+
// bytes, and a base64 blob large enough will eventually spell a token
|
|
2313
|
+
// shape by chance — which used to delete a slab out of the middle of it
|
|
2314
|
+
// and hand the client an "Invalid Base64 string" protocol error. See
|
|
2315
|
+
// RunOptions.opaqueFields.
|
|
2316
|
+
const info = JSON.parse(await run(args, { account, opaqueFields: ['contentBase64'] })) as InlineAttachment;
|
|
2292
2317
|
const path = info.path ?? outPath;
|
|
2293
2318
|
|
|
2319
|
+
// BACKSTOP, not the fix — the redaction exemption above is. Bytes that
|
|
2320
|
+
// cannot round-trip as base64 must never be handed to the SDK, which
|
|
2321
|
+
// rejects them as a -32602 protocol error the caller cannot act on. The
|
|
2322
|
+
// file itself was still written server-side, so dropping the inline copy
|
|
2323
|
+
// degrades to the path/Drive channel rather than losing the attachment.
|
|
2324
|
+
const inlineUnusable = info.contentBase64 !== undefined && !isValidBase64(info.contentBase64);
|
|
2325
|
+
if (inlineUnusable) {
|
|
2326
|
+
delete info.contentBase64;
|
|
2327
|
+
notes.push(
|
|
2328
|
+
'The inline copy of this attachment was dropped: the bytes returned by the server were not ' +
|
|
2329
|
+
'valid base64, so returning them would have failed as a protocol error. The file itself was ' +
|
|
2330
|
+
'downloaded successfully and is delivered below.',
|
|
2331
|
+
);
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2294
2334
|
// 4. Resolve the real filename/MIME when it is still unknown. gog's own
|
|
2295
2335
|
// --inline response carries the part metadata whenever its lookup hit, so
|
|
2296
2336
|
// prefer that; the size heuristic is the last resort and applies only to
|
|
@@ -2327,6 +2367,13 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2327
2367
|
? inlineImageResult(summary, info.contentBase64, mimeType)
|
|
2328
2368
|
: inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
|
|
2329
2369
|
}
|
|
2370
|
+
if (inlineUnusable) {
|
|
2371
|
+
return errorResult(
|
|
2372
|
+
`The bytes returned for ${filename} were not valid base64, so they cannot be delivered inline ` +
|
|
2373
|
+
'(the MCP transport would reject them as a protocol error). The file WAS downloaded and is ' +
|
|
2374
|
+
`readable server-side at ${path}. Use deliver="auto" or deliver="drive" to receive it.`,
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2330
2377
|
return errorResult(
|
|
2331
2378
|
`Attachment is too large to return inline (${info.reason ?? "exceeds gog's inline size limit, 3 MiB by default — raise inlineMaxBytes"}). ` +
|
|
2332
2379
|
'Use deliver="auto" or deliver="drive" to receive it as a Google Drive link.',
|
|
@@ -2844,7 +2891,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2844
2891
|
replyTo: z.string().optional().describe('Reply-To header address'),
|
|
2845
2892
|
quote: z.boolean().optional().describe('Include quoted original message in reply (requires replyToMessageId or replyToThreadId)'),
|
|
2846
2893
|
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.'),
|
|
2847
|
-
attach: z.array(z.string()).optional().describe('
|
|
2894
|
+
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. Read on the 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).'),
|
|
2895
|
+
attachInline: attachInlineParam,
|
|
2848
2896
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
2849
2897
|
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.'),
|
|
2850
2898
|
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.'),
|
|
@@ -2866,6 +2914,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2866
2914
|
quote?: boolean;
|
|
2867
2915
|
replyAll?: boolean;
|
|
2868
2916
|
attach?: string[];
|
|
2917
|
+
attachInline?: InlineAttachmentInput[];
|
|
2869
2918
|
from?: string;
|
|
2870
2919
|
autoFromAddressedAlias?: boolean;
|
|
2871
2920
|
omitRecipients?: boolean;
|
|
@@ -2894,6 +2943,12 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2894
2943
|
if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
|
|
2895
2944
|
if (f.quote) args.push('--quote');
|
|
2896
2945
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
2946
|
+
// Same repeatable --attach flag, but the bytes travel with the call: the
|
|
2947
|
+
// executor writes each one to a temp file beside gog and passes that path.
|
|
2948
|
+
// This is the only attachment route that works when the caller and gog do
|
|
2949
|
+
// not share a filesystem (hosted connector, GOG_RUNNER_URL backend).
|
|
2950
|
+
// `args` is passed so the size check sees the body, which shares the budget.
|
|
2951
|
+
args.push(...inlineAttachmentArgs('attach', f.attachInline, args));
|
|
2897
2952
|
if (f.from) args.push(`--from=${f.from}`);
|
|
2898
2953
|
// PINNED, not conditional: GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS in the host env
|
|
2899
2954
|
// silently changes which address the mail goes out FROM, with nothing in the arg
|
|
@@ -3178,7 +3233,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3178
3233
|
remove: z.array(z.string()).optional().describe('Remove these recipients from all fields (repeatable) — e.g. to drop someone from a reply-all.'),
|
|
3179
3234
|
subject: z.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
3180
3235
|
noQuote: z.boolean().optional().describe('Do not include the original message quoted below the reply (default: the original is quoted)'),
|
|
3181
|
-
attach: z.array(z.string()).optional().describe('
|
|
3236
|
+
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. Read on the server, base64-encoded with a MIME type inferred from the extension.'),
|
|
3237
|
+
attachInline: attachInlineParam,
|
|
3182
3238
|
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
3183
3239
|
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.'),
|
|
3184
3240
|
signature: z.boolean().optional().describe('Append the Gmail signature from the active send-as address'),
|
|
@@ -3198,6 +3254,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3198
3254
|
subject?: string;
|
|
3199
3255
|
noQuote?: boolean;
|
|
3200
3256
|
attach?: string[];
|
|
3257
|
+
attachInline?: InlineAttachmentInput[];
|
|
3201
3258
|
from?: string;
|
|
3202
3259
|
autoFromAddressedAlias?: boolean;
|
|
3203
3260
|
signature?: boolean;
|
|
@@ -3217,6 +3274,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3217
3274
|
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
3218
3275
|
if (f.noQuote) args.push('--no-quote');
|
|
3219
3276
|
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
3277
|
+
args.push(...inlineAttachmentArgs('attach', f.attachInline, args)); // see appendDraftFlags
|
|
3220
3278
|
if (f.from) args.push(`--from=${f.from}`);
|
|
3221
3279
|
if (f.signature) args.push('--signature');
|
|
3222
3280
|
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
|
@@ -3225,7 +3283,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3225
3283
|
}
|
|
3226
3284
|
|
|
3227
3285
|
server.registerTool('gog_gmail_reply', {
|
|
3228
|
-
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
|
|
3286
|
+
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 this same reply without sending it use gog_gmail_drafts_reply, which composes exactly what this tool would send.',
|
|
3229
3287
|
annotations: { destructiveHint: true },
|
|
3230
3288
|
inputSchema: replySchema,
|
|
3231
3289
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -3235,7 +3293,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3235
3293
|
});
|
|
3236
3294
|
|
|
3237
3295
|
server.registerTool('gog_gmail_reply_all', {
|
|
3238
|
-
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.',
|
|
3296
|
+
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. To stage it without sending use gog_gmail_drafts_reply_all.',
|
|
3239
3297
|
annotations: { destructiveHint: true },
|
|
3240
3298
|
inputSchema: replySchema,
|
|
3241
3299
|
}, async ({ messageId, account, ...flags }) => {
|
|
@@ -3244,6 +3302,76 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3244
3302
|
return runOrDiagnose(args, { account });
|
|
3245
3303
|
});
|
|
3246
3304
|
|
|
3305
|
+
// gog >= 0.36.0: the draft-side twins of reply / reply-all / forward. They
|
|
3306
|
+
// take the SAME flag set as the send-side commands and share the composition
|
|
3307
|
+
// path with them, so the schemas above are reused verbatim rather than
|
|
3308
|
+
// re-declared — the only difference is the subcommand and that NOTHING IS
|
|
3309
|
+
// SENT.
|
|
3310
|
+
//
|
|
3311
|
+
// These exist because staging a reply used to mean gog_gmail_drafts_create
|
|
3312
|
+
// with replyToMessageId/replyToThreadId, which threads the draft but does NOT
|
|
3313
|
+
// inherit the original's recipients or quote its body — the caller had to
|
|
3314
|
+
// rebuild both by hand, and a missed Cc is invisible until the draft goes
|
|
3315
|
+
// out. Here the inheritance is gog's, identical to what the send path would
|
|
3316
|
+
// have produced.
|
|
3317
|
+
const draftReplyNote =
|
|
3318
|
+
' Composes exactly what gog_gmail_reply%s would send — inherited recipients, "Re:" subject and quoted ' +
|
|
3319
|
+
'original — but SAVES IT AS A DRAFT instead of sending. Nothing leaves the mailbox; send it later with ' +
|
|
3320
|
+
'gog_gmail_drafts_send, or edit it first with gog_gmail_drafts_update (which overwrites the whole body, ' +
|
|
3321
|
+
'quote included — read the draft back before editing).';
|
|
3322
|
+
|
|
3323
|
+
server.registerTool('gog_gmail_drafts_reply', {
|
|
3324
|
+
description:
|
|
3325
|
+
'Save a reply to a Gmail message as a draft (to the original sender only).' + draftReplyNote.replace('%s', '') +
|
|
3326
|
+
' Prefer this over gog_gmail_drafts_create + replyToMessageId when the draft is a real reply: that route threads ' +
|
|
3327
|
+
'the draft but leaves recipients and quoting for you to reconstruct.',
|
|
3328
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull },
|
|
3329
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
3330
|
+
const args: GogArg[] = ['gmail', 'drafts', 'reply', messageId];
|
|
3331
|
+
appendReplyFlags(args, flags);
|
|
3332
|
+
return writeDraft(args, account, returnFull);
|
|
3333
|
+
});
|
|
3334
|
+
|
|
3335
|
+
server.registerTool('gog_gmail_drafts_reply_all', {
|
|
3336
|
+
description:
|
|
3337
|
+
'Save a reply-all to a Gmail message as a draft (sender plus every To/Cc recipient).' +
|
|
3338
|
+
draftReplyNote.replace('%s', '_all') +
|
|
3339
|
+
' Use the remove flag to drop recipients BEFORE the draft exists, rather than editing them out afterwards.',
|
|
3340
|
+
inputSchema: { ...replySchema, returnFull: draftWriteSchema.returnFull },
|
|
3341
|
+
}, async ({ messageId, account, returnFull, ...flags }) => {
|
|
3342
|
+
const args: GogArg[] = ['gmail', 'drafts', 'reply-all', messageId];
|
|
3343
|
+
appendReplyFlags(args, flags);
|
|
3344
|
+
return writeDraft(args, account, returnFull);
|
|
3345
|
+
});
|
|
3346
|
+
|
|
3347
|
+
server.registerTool('gog_gmail_drafts_forward', {
|
|
3348
|
+
description:
|
|
3349
|
+
'Save a forward of a Gmail message as a draft. Same composition as gog_gmail_forward — the original ' +
|
|
3350
|
+
'message quoted below an optional note, with its attachments carried over — but nothing is sent. ' +
|
|
3351
|
+
'Unlike gog_gmail_forward, `to` is OPTIONAL here: omit it to stage a recipient-less forward as an ' +
|
|
3352
|
+
'accidental-send guard, then add recipients with gog_gmail_drafts_update before gog_gmail_drafts_send.',
|
|
3353
|
+
inputSchema: {
|
|
3354
|
+
messageId: z.string().describe('Gmail message ID to forward'),
|
|
3355
|
+
to: z.string().optional().describe('Recipients (comma-separated). Optional for a draft — omit to stage the forward without recipients.'),
|
|
3356
|
+
cc: z.string().optional().describe('CC recipients (comma-separated)'),
|
|
3357
|
+
bcc: z.string().optional().describe('BCC recipients (comma-separated)'),
|
|
3358
|
+
note: z.string().optional().describe('Introductory text above the forwarded message'),
|
|
3359
|
+
from: z.string().optional().describe('Send from this email address (must be a verified send-as alias)'),
|
|
3360
|
+
skipAttachments: z.boolean().optional().describe('Do not include original attachments'),
|
|
3361
|
+
returnFull: draftWriteSchema.returnFull,
|
|
3362
|
+
account: accountParam,
|
|
3363
|
+
},
|
|
3364
|
+
}, async ({ messageId, to, cc, bcc, note, from, skipAttachments, returnFull, account }) => {
|
|
3365
|
+
const args: GogArg[] = ['gmail', 'drafts', 'forward', messageId];
|
|
3366
|
+
if (to) args.push(`--to=${to}`);
|
|
3367
|
+
if (cc) args.push(`--cc=${cc}`);
|
|
3368
|
+
if (bcc) args.push(`--bcc=${bcc}`);
|
|
3369
|
+
if (note) args.push(payloadArg('note', 'note-file', note));
|
|
3370
|
+
if (from) args.push(`--from=${from}`);
|
|
3371
|
+
if (skipAttachments) args.push('--skip-attachments');
|
|
3372
|
+
return writeDraft(args, account, returnFull);
|
|
3373
|
+
});
|
|
3374
|
+
|
|
3247
3375
|
server.registerTool('gog_gmail_autoreply', {
|
|
3248
3376
|
description: 'Reply once to all messages matching a Gmail search query. Use the label flag to dedupe across runs.',
|
|
3249
3377
|
annotations: { destructiveHint: true },
|
|
@@ -322,6 +322,106 @@ describe('gog_gmail_attachment', () => {
|
|
|
322
322
|
await call({});
|
|
323
323
|
expect((vi.mocked(lib.diagnose).mock.calls[0][0] as Error).message).toBe('the download failed on the server');
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
// ==========================================================================
|
|
327
|
+
// FILENAME INDEPENDENCE — the defect reported as "inline delivery fails on
|
|
328
|
+
// filenames containing spaces".
|
|
329
|
+
//
|
|
330
|
+
// It was never the filename. The runner spawns an argv ARRAY (never a shell),
|
|
331
|
+
// so a space has nothing to split; the real variable was the base64 content
|
|
332
|
+
// colliding with a redaction pattern. These lock in that names with spaces,
|
|
333
|
+
// non-ASCII and punctuation all deliver inline, and that the download args
|
|
334
|
+
// carry each name as ONE element.
|
|
335
|
+
// ==========================================================================
|
|
336
|
+
describe('filename independence', () => {
|
|
337
|
+
const NAMES = [
|
|
338
|
+
'image.png',
|
|
339
|
+
'Screenshot 2026-06-13 152500.png',
|
|
340
|
+
'Reçu — étude, final (v2).png',
|
|
341
|
+
"quote'and\"double.png",
|
|
342
|
+
'ファイル 名前.png',
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
for (const filename of NAMES) {
|
|
346
|
+
it(`delivers ${JSON.stringify(filename)} inline as an image`, async () => {
|
|
347
|
+
// Indexed mode resolves the real name BEFORE the download, so the name
|
|
348
|
+
// is what gets handed to gog — the strongest form of this assertion.
|
|
349
|
+
stubGog({
|
|
350
|
+
meta: { attachments: [{ filename, mimeType: 'image/png', size: 24, attachmentIndex: 0 }] },
|
|
351
|
+
download: { path: `/tmp/gog-attachments/m1/${filename}`, bytes: 24, contentBase64: PNG_B64, filename, mimeType: 'image/png' },
|
|
352
|
+
});
|
|
353
|
+
const res = await harness.callTool('gog_gmail_attachment', { messageId: 'm1', attachmentIndex: 0 });
|
|
354
|
+
const image = res.content.find((c) => c.type === 'image') as { data: string; mimeType: string };
|
|
355
|
+
expect(image).toBeDefined();
|
|
356
|
+
expect(image.data).toBe(PNG_B64);
|
|
357
|
+
// The name reaches gog as a SINGLE argv element, spaces and all.
|
|
358
|
+
expect(dlArgs()).toContain(`--name=${filename}`);
|
|
359
|
+
expect(dlArgs()).toContain(`--out=/tmp/gog-attachments/m1/${filename}`);
|
|
360
|
+
expect((res.content[0] as { text: string }).text).toContain(filename);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
it('passes a spaced --out path as one argv element, never split on whitespace', async () => {
|
|
365
|
+
const filename = 'Screenshot 2026-06-13 152500.png';
|
|
366
|
+
stubGog({ download: { bytes: 24, contentBase64: PNG_B64, filename, mimeType: 'image/png' } });
|
|
367
|
+
await call({ name: filename });
|
|
368
|
+
const args = dlArgs();
|
|
369
|
+
expect(args).toContain(`--out=/tmp/gog-attachments/m1/${filename}`);
|
|
370
|
+
// If anything had split on spaces these would appear as separate elements.
|
|
371
|
+
expect(args).not.toContain('2026-06-13');
|
|
372
|
+
expect(args).not.toContain('152500.png');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// The bytes are exempted from redaction at the runner seam; this asserts the
|
|
377
|
+
// tool actually asks for that exemption, which is the thing that keeps a
|
|
378
|
+
// `1//`-containing PNG from arriving corrupt.
|
|
379
|
+
it('requests the contentBase64 redaction exemption on the download', async () => {
|
|
380
|
+
stubGog({ download: { bytes: 24, contentBase64: PNG_B64, filename: 'a.png', mimeType: 'image/png' } });
|
|
381
|
+
await call({});
|
|
382
|
+
const call0 = vi.mocked(lib.run).mock.calls.find((c) => (c[0] as string[])[1] === 'attachment')!;
|
|
383
|
+
expect(call0[1]).toMatchObject({ opaqueFields: ['contentBase64'] });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Belt-and-braces: if bytes ever do arrive unusable, the caller must get a
|
|
387
|
+
// readable tool result, not an MCP -32602 protocol fault they cannot act on.
|
|
388
|
+
it('degrades to the file path when the returned bytes are not valid base64', async () => {
|
|
389
|
+
stubGog({
|
|
390
|
+
meta: PNG_LIST,
|
|
391
|
+
download: { path: '/tmp/gog-attachments/m1/photo.png', bytes: 24, contentBase64: 'not!valid!base64!', filename: 'photo.png', mimeType: 'image/png' },
|
|
392
|
+
});
|
|
393
|
+
const res = await call({});
|
|
394
|
+
expect(res.content.some((c) => c.type === 'image')).toBe(false);
|
|
395
|
+
expect(textOf(res)).toContain('not valid base64');
|
|
396
|
+
expect(JSON.stringify(res)).toContain('/tmp/gog-attachments/m1/photo.png');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// The MIME sniff decodes the leading bytes. On an unusable payload that decode
|
|
400
|
+
// is the FIRST thing to fail, and it must not be what surfaces — the caller's
|
|
401
|
+
// problem is the payload, not the sniff.
|
|
402
|
+
it('survives a MIME sniff of unusable bytes instead of throwing out of the sniff', async () => {
|
|
403
|
+
stubGog({
|
|
404
|
+
meta: { attachments: [] }, // nothing to resolve a MIME type from
|
|
405
|
+
download: { path: '/tmp/gog-attachments/m1/attachment', bytes: 4, contentBase64: '!!!!' },
|
|
406
|
+
});
|
|
407
|
+
const res = await call({});
|
|
408
|
+
expect(res.isError).toBeUndefined();
|
|
409
|
+
// content[0] is the dropped-inline note; the delivery payload follows it.
|
|
410
|
+
const payload = JSON.parse((res.content.at(-1) as { text: string }).text);
|
|
411
|
+
expect(payload.mimeType).toBe('application/octet-stream');
|
|
412
|
+
expect(payload.fileName).toBe('attachment');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('explains itself rather than throwing when deliver=inline gets unusable bytes', async () => {
|
|
416
|
+
stubGog({
|
|
417
|
+
meta: PNG_LIST,
|
|
418
|
+
download: { path: '/tmp/gog-attachments/m1/photo.png', bytes: 24, contentBase64: '!!!!', filename: 'photo.png', mimeType: 'image/png' },
|
|
419
|
+
});
|
|
420
|
+
const res = await call({ deliver: 'inline' });
|
|
421
|
+
expect(res.isError).toBe(true);
|
|
422
|
+
expect(textOf(res)).toContain('not valid base64');
|
|
423
|
+
expect(textOf(res)).toContain('/tmp/gog-attachments/m1/photo.png');
|
|
424
|
+
});
|
|
325
425
|
});
|
|
326
426
|
|
|
327
427
|
describe('gog_gmail_url', () => {
|
|
@@ -1290,6 +1390,69 @@ describe('gog_gmail_drafts_create', () => {
|
|
|
1290
1390
|
);
|
|
1291
1391
|
});
|
|
1292
1392
|
|
|
1393
|
+
// ==========================================================================
|
|
1394
|
+
// INLINE ATTACHMENT BYTES on drafts — the outbound half of the "no shared
|
|
1395
|
+
// filesystem" defect. `attach` paths resolve on the gog server and are
|
|
1396
|
+
// unreachable from a remote caller; attachInline carries the bytes instead.
|
|
1397
|
+
// ==========================================================================
|
|
1398
|
+
it('turns attachInline into repeatable --attach file args on drafts_create', async () => {
|
|
1399
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64');
|
|
1400
|
+
await harness.callTool('gog_gmail_drafts_create', {
|
|
1401
|
+
subject: 'Layouts',
|
|
1402
|
+
body: 'See attached',
|
|
1403
|
+
attachInline: [{ filename: 'pendant-layouts.png', contentBase64: png }],
|
|
1404
|
+
});
|
|
1405
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1406
|
+
[
|
|
1407
|
+
'gmail', 'drafts', 'create', '--subject=Layouts', '--body=See attached',
|
|
1408
|
+
{ kind: 'file', flag: 'attach', contents: png, encoding: 'base64', filename: 'pendant-layouts.png' },
|
|
1409
|
+
'--auto-from-addressed-alias=false',
|
|
1410
|
+
],
|
|
1411
|
+
{ account: undefined },
|
|
1412
|
+
);
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('keeps attach paths and attachInline bytes side by side, in that order', async () => {
|
|
1416
|
+
const bytes = Buffer.from('hello').toString('base64');
|
|
1417
|
+
await harness.callTool('gog_gmail_drafts_create', {
|
|
1418
|
+
subject: 'S', body: 'B',
|
|
1419
|
+
attach: ['/tmp/on-server.pdf'],
|
|
1420
|
+
attachInline: [{ filename: 'from-client.txt', contentBase64: bytes }],
|
|
1421
|
+
});
|
|
1422
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0];
|
|
1423
|
+
expect(args).toContain('--attach=/tmp/on-server.pdf');
|
|
1424
|
+
expect(args).toContainEqual({ kind: 'file', flag: 'attach', contents: bytes, encoding: 'base64', filename: 'from-client.txt' });
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it('supports attachInline on drafts_update too', async () => {
|
|
1428
|
+
const bytes = Buffer.from('v2').toString('base64');
|
|
1429
|
+
await harness.callTool('gog_gmail_drafts_update', {
|
|
1430
|
+
draftId: 'd1', subject: 'S', body: 'B',
|
|
1431
|
+
attachInline: [{ filename: 'revised.pdf', contentBase64: bytes }],
|
|
1432
|
+
});
|
|
1433
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0];
|
|
1434
|
+
expect(args).toContainEqual({ kind: 'file', flag: 'attach', contents: bytes, encoding: 'base64', filename: 'revised.pdf' });
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it('preserves a filename with spaces on the way to gog', async () => {
|
|
1438
|
+
await harness.callTool('gog_gmail_drafts_create', {
|
|
1439
|
+
subject: 'S', body: 'B',
|
|
1440
|
+
attachInline: [{ filename: 'Screenshot 2026-06-13 152500.png', contentBase64: Buffer.from('x').toString('base64') }],
|
|
1441
|
+
});
|
|
1442
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0];
|
|
1443
|
+
expect(args.find((a) => typeof a !== 'string')).toMatchObject({ filename: 'Screenshot 2026-06-13 152500.png' });
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
it('rejects an invalid inline attachment without writing a draft', async () => {
|
|
1447
|
+
const res = await harness.callTool('gog_gmail_drafts_create', {
|
|
1448
|
+
subject: 'S', body: 'B',
|
|
1449
|
+
attachInline: [{ filename: '../escape.png', contentBase64: Buffer.from('x').toString('base64') }],
|
|
1450
|
+
});
|
|
1451
|
+
expect(res.isError).toBe(true);
|
|
1452
|
+
expect((res.content[0] as { text: string }).text).toMatch(/must be a bare filename, not a path/);
|
|
1453
|
+
expect(lib.runOrDiagnose).not.toHaveBeenCalled();
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1293
1456
|
it('passes --body-html-file when bodyHtmlFile is supplied', async () => {
|
|
1294
1457
|
await harness.callTool('gog_gmail_drafts_create', {
|
|
1295
1458
|
subject: 'Hi',
|
|
@@ -1669,6 +1832,139 @@ describe('gog_gmail_reply_all', () => {
|
|
|
1669
1832
|
});
|
|
1670
1833
|
});
|
|
1671
1834
|
|
|
1835
|
+
// gog 0.36.0 (openclaw/gogcli#977) added the draft-side twins of reply /
|
|
1836
|
+
// reply-all / forward. The point of these tests is the SUBCOMMAND: the flag
|
|
1837
|
+
// handling is the send path's, shared verbatim, and a copy of it here would
|
|
1838
|
+
// only re-assert what the reply tests above already pin. What is new — and what
|
|
1839
|
+
// a regression would silently break — is that these route to `drafts <verb>`
|
|
1840
|
+
// and therefore never send.
|
|
1841
|
+
describe('gog_gmail_drafts_reply', () => {
|
|
1842
|
+
it('routes to gmail drafts reply, not the sending reply', async () => {
|
|
1843
|
+
await harness.callTool('gog_gmail_drafts_reply', { messageId: 'm1', body: 'Thanks' });
|
|
1844
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1845
|
+
['gmail', 'drafts', 'reply', 'm1', '--body=Thanks', '--auto-from-addressed-alias=false'],
|
|
1846
|
+
{ account: undefined },
|
|
1847
|
+
);
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('passes the shared reply flag set through unchanged', async () => {
|
|
1851
|
+
await harness.callTool('gog_gmail_drafts_reply', {
|
|
1852
|
+
messageId: 'm1',
|
|
1853
|
+
body: 'Hi',
|
|
1854
|
+
to: ['a@b.com'],
|
|
1855
|
+
cc: ['cc@x.com'],
|
|
1856
|
+
remove: ['old@x.com'],
|
|
1857
|
+
subject: 'New subject',
|
|
1858
|
+
noQuote: true,
|
|
1859
|
+
attach: ['/tmp/a.pdf'],
|
|
1860
|
+
from: 'me@x.com',
|
|
1861
|
+
signature: true,
|
|
1862
|
+
account: 'me@gmail.com',
|
|
1863
|
+
});
|
|
1864
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1865
|
+
[
|
|
1866
|
+
'gmail', 'drafts', 'reply', 'm1',
|
|
1867
|
+
'--body=Hi',
|
|
1868
|
+
'--to=a@b.com',
|
|
1869
|
+
'--cc=cc@x.com',
|
|
1870
|
+
'--remove=old@x.com',
|
|
1871
|
+
'--subject=New subject',
|
|
1872
|
+
'--no-quote',
|
|
1873
|
+
'--attach=/tmp/a.pdf',
|
|
1874
|
+
'--from=me@x.com',
|
|
1875
|
+
'--signature',
|
|
1876
|
+
'--auto-from-addressed-alias=false',
|
|
1877
|
+
],
|
|
1878
|
+
{ account: 'me@gmail.com' },
|
|
1879
|
+
);
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
it('returnFull re-fetches the saved draft and never reaches the CLI as a flag', async () => {
|
|
1883
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1884
|
+
.mockResolvedValueOnce(rawTextResult('{"draftId":"d9"}'))
|
|
1885
|
+
.mockResolvedValueOnce(rawTextResult('{"id":"d9","message":{"subject":"Re: Hi"}}'));
|
|
1886
|
+
const result = await harness.callTool('gog_gmail_drafts_reply', {
|
|
1887
|
+
messageId: 'm1', body: 'Hi', returnFull: true,
|
|
1888
|
+
});
|
|
1889
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(1,
|
|
1890
|
+
['gmail', 'drafts', 'reply', 'm1', '--body=Hi', '--auto-from-addressed-alias=false'], { account: undefined });
|
|
1891
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
1892
|
+
['gmail', 'drafts', 'get', 'd9', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
1893
|
+
expect(result.content[0].text).toContain('"subject":"Re: Hi"');
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
describe('gog_gmail_drafts_reply_all', () => {
|
|
1898
|
+
it('routes to gmail drafts reply-all', async () => {
|
|
1899
|
+
await harness.callTool('gog_gmail_drafts_reply_all', { messageId: 'm1', body: 'Thanks all' });
|
|
1900
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1901
|
+
['gmail', 'drafts', 'reply-all', 'm1', '--body=Thanks all', '--auto-from-addressed-alias=false'],
|
|
1902
|
+
{ account: undefined },
|
|
1903
|
+
);
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
it('carries repeatable recipient removals onto the draft', async () => {
|
|
1907
|
+
await harness.callTool('gog_gmail_drafts_reply_all', {
|
|
1908
|
+
messageId: 'm1', body: 'Hi', remove: ['drop@y.com', 'also@y.com'],
|
|
1909
|
+
});
|
|
1910
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1911
|
+
[
|
|
1912
|
+
'gmail', 'drafts', 'reply-all', 'm1',
|
|
1913
|
+
'--body=Hi',
|
|
1914
|
+
'--remove=drop@y.com',
|
|
1915
|
+
'--remove=also@y.com',
|
|
1916
|
+
'--auto-from-addressed-alias=false',
|
|
1917
|
+
],
|
|
1918
|
+
{ account: undefined },
|
|
1919
|
+
);
|
|
1920
|
+
});
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
describe('gog_gmail_drafts_forward', () => {
|
|
1924
|
+
it('omits --to entirely when no recipients are given', async () => {
|
|
1925
|
+
await harness.callTool('gog_gmail_drafts_forward', { messageId: 'm1' });
|
|
1926
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1927
|
+
['gmail', 'drafts', 'forward', 'm1'],
|
|
1928
|
+
{ account: undefined },
|
|
1929
|
+
);
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it('passes every forward flag', async () => {
|
|
1933
|
+
await harness.callTool('gog_gmail_drafts_forward', {
|
|
1934
|
+
messageId: 'm1',
|
|
1935
|
+
to: 'a@b.com,c@d.com',
|
|
1936
|
+
cc: 'cc@x.com',
|
|
1937
|
+
bcc: 'bcc@x.com',
|
|
1938
|
+
note: 'FYI',
|
|
1939
|
+
from: 'me@x.com',
|
|
1940
|
+
skipAttachments: true,
|
|
1941
|
+
account: 'me@gmail.com',
|
|
1942
|
+
});
|
|
1943
|
+
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1944
|
+
[
|
|
1945
|
+
'gmail', 'drafts', 'forward', 'm1',
|
|
1946
|
+
'--to=a@b.com,c@d.com',
|
|
1947
|
+
'--cc=cc@x.com',
|
|
1948
|
+
'--bcc=bcc@x.com',
|
|
1949
|
+
'--note=FYI',
|
|
1950
|
+
'--from=me@x.com',
|
|
1951
|
+
'--skip-attachments',
|
|
1952
|
+
],
|
|
1953
|
+
{ account: 'me@gmail.com' },
|
|
1954
|
+
);
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
it('returnFull re-fetches the saved forward draft', async () => {
|
|
1958
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1959
|
+
.mockResolvedValueOnce(rawTextResult('{"draftId":"d7"}'))
|
|
1960
|
+
.mockResolvedValueOnce(rawTextResult('{"id":"d7","message":{"subject":"Fwd: Hi"}}'));
|
|
1961
|
+
const result = await harness.callTool('gog_gmail_drafts_forward', { messageId: 'm1', returnFull: true });
|
|
1962
|
+
expect(lib.runOrDiagnose).toHaveBeenNthCalledWith(2,
|
|
1963
|
+
['gmail', 'drafts', 'get', 'd7', '--use-indexed-attachment-ids=false'], { account: undefined });
|
|
1964
|
+
expect(result.content[0].text).toContain('"subject":"Fwd: Hi"');
|
|
1965
|
+
});
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1672
1968
|
describe('gog_gmail_autoreply', () => {
|
|
1673
1969
|
it('calls runOrDiagnose with query and --body', async () => {
|
|
1674
1970
|
await harness.callTool('gog_gmail_autoreply', { query: 'is:unread', body: 'Thanks' });
|