gogcli-mcp-gmail 2.24.0 → 2.26.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/dist/index.js +244 -63
- package/manifest.json +8 -8
- package/mint.yaml +107 -0
- package/package.json +2 -2
- package/src/tools/gmail-extra.ts +65 -7
- package/tests/tools/gmail-extra.test.ts +163 -0
package/dist/index.js
CHANGED
|
@@ -31000,9 +31000,87 @@ var StdioServerTransport = class {
|
|
|
31000
31000
|
}
|
|
31001
31001
|
};
|
|
31002
31002
|
|
|
31003
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
31004
|
+
var McpToolError = class extends Error {
|
|
31005
|
+
/** Actionable remediation text, when one applies. */
|
|
31006
|
+
hint;
|
|
31007
|
+
constructor(message, opts) {
|
|
31008
|
+
super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
|
|
31009
|
+
this.name = "McpToolError";
|
|
31010
|
+
if (opts?.hint !== void 0)
|
|
31011
|
+
this.hint = opts.hint;
|
|
31012
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
31013
|
+
}
|
|
31014
|
+
};
|
|
31015
|
+
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
31016
|
+
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
31017
|
+
var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
31018
|
+
var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
|
|
31019
|
+
var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
|
|
31020
|
+
var API_KEY_RE = new RegExp([
|
|
31021
|
+
"sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
|
|
31022
|
+
// OpenAI / Anthropic (incl. sk-ant-…)
|
|
31023
|
+
"gh[pousr]_[A-Za-z0-9]{36,}\\b",
|
|
31024
|
+
// GitHub ghp_/gho_/ghu_/ghs_/ghr_
|
|
31025
|
+
"xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
|
|
31026
|
+
// Slack
|
|
31027
|
+
"AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
|
|
31028
|
+
// Google API key (39 chars total)
|
|
31029
|
+
"AKIA[0-9A-Z]{16}\\b",
|
|
31030
|
+
// AWS access key id (20 chars total)
|
|
31031
|
+
"whsec_[A-Za-z0-9]{16,}\\b"
|
|
31032
|
+
// webhook signing secret (Stripe-style)
|
|
31033
|
+
].map((p) => `\\b${p}`).join("|"), "g");
|
|
31034
|
+
var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
31035
|
+
var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
|
|
31036
|
+
var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
|
|
31037
|
+
var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
|
|
31038
|
+
var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
|
|
31039
|
+
function redactSecrets(text) {
|
|
31040
|
+
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
31041
|
+
}
|
|
31042
|
+
|
|
31043
|
+
// ../../node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
31044
|
+
function textResult(data) {
|
|
31045
|
+
return {
|
|
31046
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
31047
|
+
};
|
|
31048
|
+
}
|
|
31049
|
+
function rawTextResult(text) {
|
|
31050
|
+
return { content: [{ type: "text", text }] };
|
|
31051
|
+
}
|
|
31052
|
+
function errorResult(message) {
|
|
31053
|
+
return {
|
|
31054
|
+
content: [{ type: "text", text: redactSecrets(message) }],
|
|
31055
|
+
isError: true
|
|
31056
|
+
};
|
|
31057
|
+
}
|
|
31058
|
+
|
|
31003
31059
|
// ../../node_modules/@chrischall/mcp-utils/dist/server/index.js
|
|
31060
|
+
function hintResultOrRethrow(err) {
|
|
31061
|
+
if (err instanceof McpToolError && err.hint) {
|
|
31062
|
+
return errorResult(`${err.message}
|
|
31063
|
+
|
|
31064
|
+
Hint: ${err.hint}`);
|
|
31065
|
+
}
|
|
31066
|
+
throw err;
|
|
31067
|
+
}
|
|
31068
|
+
function surfaceToolHints(server) {
|
|
31069
|
+
const register = server.registerTool.bind(server);
|
|
31070
|
+
server.registerTool = (name, config2, cb) => register(name, config2, (...args) => {
|
|
31071
|
+
let result;
|
|
31072
|
+
try {
|
|
31073
|
+
result = cb(...args);
|
|
31074
|
+
} catch (err) {
|
|
31075
|
+
return hintResultOrRethrow(err);
|
|
31076
|
+
}
|
|
31077
|
+
return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
|
|
31078
|
+
});
|
|
31079
|
+
}
|
|
31004
31080
|
async function createMcpServer(opts) {
|
|
31005
31081
|
const server = new McpServer({ name: opts.name, version: opts.version });
|
|
31082
|
+
if (opts.surfaceHints !== false)
|
|
31083
|
+
surfaceToolHints(server);
|
|
31006
31084
|
if (opts.banner !== void 0) {
|
|
31007
31085
|
console.error(opts.banner);
|
|
31008
31086
|
}
|
|
@@ -31047,51 +31125,6 @@ async function runMcp(opts) {
|
|
|
31047
31125
|
return server;
|
|
31048
31126
|
}
|
|
31049
31127
|
|
|
31050
|
-
// ../../node_modules/@chrischall/mcp-utils/dist/errors/index.js
|
|
31051
|
-
var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
31052
|
-
var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
31053
|
-
var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
|
|
31054
|
-
var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
|
|
31055
|
-
var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
|
|
31056
|
-
var API_KEY_RE = new RegExp([
|
|
31057
|
-
"sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
|
|
31058
|
-
// OpenAI / Anthropic (incl. sk-ant-…)
|
|
31059
|
-
"gh[pousr]_[A-Za-z0-9]{36,}\\b",
|
|
31060
|
-
// GitHub ghp_/gho_/ghu_/ghs_/ghr_
|
|
31061
|
-
"xox[baprs]-[A-Za-z0-9-]{10,}(?![A-Za-z0-9-])",
|
|
31062
|
-
// Slack
|
|
31063
|
-
"AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
|
|
31064
|
-
// Google API key (39 chars total)
|
|
31065
|
-
"AKIA[0-9A-Z]{16}\\b",
|
|
31066
|
-
// AWS access key id (20 chars total)
|
|
31067
|
-
"whsec_[A-Za-z0-9]{16,}\\b"
|
|
31068
|
-
// webhook signing secret (Stripe-style)
|
|
31069
|
-
].map((p) => `\\b${p}`).join("|"), "g");
|
|
31070
|
-
var QUERY_SECRET_RE = /([?&](?:access_token|refresh_token|client_secret|api_?key|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
|
|
31071
|
-
var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
|
|
31072
|
-
var JSON_SECRET_KEYS = "access_token|refresh_token|client_secret|api_?key|password|passwd|secret|token";
|
|
31073
|
-
var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")[^"]*(")`, "gi");
|
|
31074
|
-
var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')[^']*(')`, "gi");
|
|
31075
|
-
function redactSecrets(text) {
|
|
31076
|
-
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
31077
|
-
}
|
|
31078
|
-
|
|
31079
|
-
// ../../node_modules/@chrischall/mcp-utils/dist/response/index.js
|
|
31080
|
-
function textResult(data) {
|
|
31081
|
-
return {
|
|
31082
|
-
content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
|
|
31083
|
-
};
|
|
31084
|
-
}
|
|
31085
|
-
function rawTextResult(text) {
|
|
31086
|
-
return { content: [{ type: "text", text }] };
|
|
31087
|
-
}
|
|
31088
|
-
function errorResult(message) {
|
|
31089
|
-
return {
|
|
31090
|
-
content: [{ type: "text", text: redactSecrets(message) }],
|
|
31091
|
-
isError: true
|
|
31092
|
-
};
|
|
31093
|
-
}
|
|
31094
|
-
|
|
31095
31128
|
// ../../node_modules/@chrischall/mcp-utils/dist/config/index.js
|
|
31096
31129
|
var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
|
|
31097
31130
|
function readEnvVar(key, opts = {}) {
|
|
@@ -31182,10 +31215,11 @@ function sanitizedEnv() {
|
|
|
31182
31215
|
}
|
|
31183
31216
|
return result;
|
|
31184
31217
|
}
|
|
31218
|
+
var TOKEN_LEFT_BOUNDARY = "(?<![A-Za-z0-9+/])";
|
|
31185
31219
|
var GOOGLE_TOKEN_PATTERNS = [
|
|
31186
|
-
|
|
31220
|
+
new RegExp(`${TOKEN_LEFT_BOUNDARY}ya29\\.[A-Za-z0-9._\\-]+`, "g"),
|
|
31187
31221
|
// OAuth2 access tokens
|
|
31188
|
-
|
|
31222
|
+
new RegExp(`${TOKEN_LEFT_BOUNDARY}1//[A-Za-z0-9._\\-]+`, "g")
|
|
31189
31223
|
// OAuth2 refresh tokens
|
|
31190
31224
|
];
|
|
31191
31225
|
function redactGoogleTokens(text) {
|
|
@@ -31198,6 +31232,26 @@ function redactGoogleTokens(text) {
|
|
|
31198
31232
|
function redactSecrets2(text) {
|
|
31199
31233
|
return redactGoogleTokens(redactSecrets(text));
|
|
31200
31234
|
}
|
|
31235
|
+
var OPAQUE_FIELD_VALUE = "[A-Za-z0-9+/_-]{16,}={0,2}";
|
|
31236
|
+
var opaquePlaceholder = (i) => `\0gogOpaque${i}\0`;
|
|
31237
|
+
function redactPreservingOpaqueFields(text, fields, redact) {
|
|
31238
|
+
const lifted = [];
|
|
31239
|
+
let staged = text;
|
|
31240
|
+
for (const field of fields) {
|
|
31241
|
+
const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31242
|
+
const re = new RegExp(`("${escaped}"\\s*:\\s*")(${OPAQUE_FIELD_VALUE})(")`, "g");
|
|
31243
|
+
staged = staged.replace(re, (_m, open, value, close) => {
|
|
31244
|
+
lifted.push(value);
|
|
31245
|
+
return `${open}${opaquePlaceholder(lifted.length - 1)}${close}`;
|
|
31246
|
+
});
|
|
31247
|
+
}
|
|
31248
|
+
if (lifted.length === 0) return redact(text);
|
|
31249
|
+
let redacted = redact(staged);
|
|
31250
|
+
lifted.forEach((value, i) => {
|
|
31251
|
+
redacted = redacted.split(opaquePlaceholder(i)).join(value);
|
|
31252
|
+
});
|
|
31253
|
+
return redacted;
|
|
31254
|
+
}
|
|
31201
31255
|
function augmentedPath() {
|
|
31202
31256
|
const home = process.env.HOME;
|
|
31203
31257
|
const candidates = [
|
|
@@ -31228,19 +31282,24 @@ function formatTimeout(ms) {
|
|
|
31228
31282
|
return `${ms}ms`;
|
|
31229
31283
|
}
|
|
31230
31284
|
async function spawnWithTempFiles(args, opts) {
|
|
31231
|
-
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
31285
|
+
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
|
|
31232
31286
|
const { tmpdir } = await import("node:os");
|
|
31233
31287
|
const dir = await mkdtemp(join(tmpdir(), "gogcli-mcp-"));
|
|
31234
31288
|
try {
|
|
31235
31289
|
const argv = [];
|
|
31290
|
+
let seq = 0;
|
|
31236
31291
|
for (const arg of args) {
|
|
31237
31292
|
if (!isGogFileArg(arg)) {
|
|
31238
31293
|
argv.push(arg);
|
|
31239
31294
|
continue;
|
|
31240
31295
|
}
|
|
31241
|
-
const
|
|
31242
|
-
|
|
31243
|
-
|
|
31296
|
+
const sub = join(dir, String(seq));
|
|
31297
|
+
seq += 1;
|
|
31298
|
+
await mkdir(sub, { recursive: true, mode: 448 });
|
|
31299
|
+
const path = join(sub, arg.filename ?? `${arg.flag}.${arg.ext ?? "txt"}`);
|
|
31300
|
+
const data = arg.encoding === "base64" ? Buffer.from(arg.contents, "base64") : Buffer.from(arg.contents, "utf8");
|
|
31301
|
+
await writeFile(path, data, { mode: 384 });
|
|
31302
|
+
argv.push(arg.positional ? path : `--${arg.flag}=${path}`);
|
|
31244
31303
|
}
|
|
31245
31304
|
return await spawnGog(argv, opts);
|
|
31246
31305
|
} finally {
|
|
@@ -31325,8 +31384,9 @@ function assembleArgs(args, opts) {
|
|
|
31325
31384
|
return fullArgs;
|
|
31326
31385
|
}
|
|
31327
31386
|
async function run(args, options = {}) {
|
|
31328
|
-
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full" } = options;
|
|
31329
|
-
const
|
|
31387
|
+
const { account, spawner, interactive = false, timeout, readonly: readonly2 = false, redactMode = "full", opaqueFields } = options;
|
|
31388
|
+
const base = redactMode === "tokens" ? redactGoogleTokens : redactSecrets2;
|
|
31389
|
+
const redact = opaqueFields?.length ? (text) => redactPreservingOpaqueFields(text, opaqueFields, base) : base;
|
|
31330
31390
|
const fullArgs = assembleArgs(args, { account, interactive, readonly: readonly2 });
|
|
31331
31391
|
const store = activeExecutor();
|
|
31332
31392
|
try {
|
|
@@ -31340,7 +31400,7 @@ async function run(args, options = {}) {
|
|
|
31340
31400
|
}
|
|
31341
31401
|
return redact(output);
|
|
31342
31402
|
} catch (err) {
|
|
31343
|
-
const message =
|
|
31403
|
+
const message = base(err instanceof Error ? err.message : String(err));
|
|
31344
31404
|
if (isRunnerTransportError(err)) {
|
|
31345
31405
|
throw new RunnerTransportError(message, err.kind, err.status);
|
|
31346
31406
|
}
|
|
@@ -31881,6 +31941,95 @@ function authToolsFor(defaultServices) {
|
|
|
31881
31941
|
return (server) => registerAuthToolsWith(server, defaultServices);
|
|
31882
31942
|
}
|
|
31883
31943
|
|
|
31944
|
+
// ../gogcli-mcp/src/tools/calendar.ts
|
|
31945
|
+
var reminderParams = {
|
|
31946
|
+
reminders: external_exports.array(external_exports.string()).max(5).optional().describe(
|
|
31947
|
+
`Reminders as method:duration, e.g. ["popup:30m", "email:1d"]. Method is popup or email; duration accepts m/h/d (max 40320 minutes = 4 weeks). Google allows at most 5. These REPLACE the event's reminders \u2014 on update, pass an EMPTY array to drop custom reminders and go back to the calendar's defaults. Cannot be combined with noReminders.`
|
|
31948
|
+
),
|
|
31949
|
+
noReminders: external_exports.boolean().optional().describe(
|
|
31950
|
+
"Give the event no reminders at all, overriding the calendar's defaults. Different from an empty reminders array, which RESTORES those defaults. Cannot be combined with reminders."
|
|
31951
|
+
)
|
|
31952
|
+
};
|
|
31953
|
+
|
|
31954
|
+
// ../gogcli-mcp/src/attachments.ts
|
|
31955
|
+
var MAX_INLINE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
|
31956
|
+
var RUNNER_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
31957
|
+
var RUNNER_BODY_JSON_RESERVE_BYTES = 256 * 1024;
|
|
31958
|
+
var MAX_REQUEST_PAYLOAD_WIRE_BYTES = RUNNER_MAX_BODY_BYTES - RUNNER_BODY_JSON_RESERVE_BYTES;
|
|
31959
|
+
var MAX_INLINE_ATTACHMENT_TOTAL_BYTES = Math.floor(MAX_REQUEST_PAYLOAD_WIRE_BYTES * 3 / 4);
|
|
31960
|
+
function wireBytesOf(arg) {
|
|
31961
|
+
if (typeof arg === "string") return Buffer.byteLength(arg, "utf8");
|
|
31962
|
+
return arg.encoding === "base64" ? arg.contents.length : Buffer.byteLength(arg.contents, "utf8");
|
|
31963
|
+
}
|
|
31964
|
+
var formatMiB = (bytes) => `${Math.floor(bytes / (1024 * 1024))} MiB`;
|
|
31965
|
+
var INLINE_ATTACHMENT_LIMITS_TEXT = `up to ${formatMiB(MAX_INLINE_ATTACHMENT_BYTES)} per file and ${formatMiB(MAX_INLINE_ATTACHMENT_TOTAL_BYTES)} in total`;
|
|
31966
|
+
var inlineAttachmentSchema = external_exports.object({
|
|
31967
|
+
filename: external_exports.string().min(1).describe(
|
|
31968
|
+
`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.`
|
|
31969
|
+
),
|
|
31970
|
+
contentBase64: external_exports.string().min(1).describe(
|
|
31971
|
+
"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."
|
|
31972
|
+
)
|
|
31973
|
+
});
|
|
31974
|
+
var attachInlineParam = external_exports.array(inlineAttachmentSchema).optional().describe(
|
|
31975
|
+
`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.`
|
|
31976
|
+
);
|
|
31977
|
+
function validateFilename(filename, where) {
|
|
31978
|
+
if (/[/\\]/.test(filename)) {
|
|
31979
|
+
throw new Error(
|
|
31980
|
+
`${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".`
|
|
31981
|
+
);
|
|
31982
|
+
}
|
|
31983
|
+
if (/[\x00-\x1f]/.test(filename) || /^\.+$/.test(filename) || filename.length > 200) {
|
|
31984
|
+
throw new Error(
|
|
31985
|
+
`${where}: filename ${JSON.stringify(filename)} is not a usable filename (no control characters, not "."/"..", 200 characters max).`
|
|
31986
|
+
);
|
|
31987
|
+
}
|
|
31988
|
+
}
|
|
31989
|
+
function decodedLength(contentBase64) {
|
|
31990
|
+
const buf = Buffer.from(contentBase64, "base64");
|
|
31991
|
+
return buf.toString("base64") === contentBase64 ? buf.length : null;
|
|
31992
|
+
}
|
|
31993
|
+
function inlineFileArg(flag, attachment, opts = {}) {
|
|
31994
|
+
const { filename, contentBase64 } = attachment;
|
|
31995
|
+
const where = opts.where ?? `attachInline entry ${JSON.stringify(filename)}`;
|
|
31996
|
+
validateFilename(filename, where);
|
|
31997
|
+
const bytes = decodedLength(contentBase64);
|
|
31998
|
+
if (bytes === null) {
|
|
31999
|
+
throw new Error(
|
|
32000
|
+
`${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.`
|
|
32001
|
+
);
|
|
32002
|
+
}
|
|
32003
|
+
if (bytes > MAX_INLINE_ATTACHMENT_BYTES) {
|
|
32004
|
+
throw new Error(
|
|
32005
|
+
`${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.`
|
|
32006
|
+
);
|
|
32007
|
+
}
|
|
32008
|
+
const arg = { kind: "file", flag, contents: contentBase64, encoding: "base64", filename };
|
|
32009
|
+
if (opts.positional) arg.positional = true;
|
|
32010
|
+
return { arg, bytes };
|
|
32011
|
+
}
|
|
32012
|
+
function inlineAttachmentArgs(flag, attachments, siblingArgs = []) {
|
|
32013
|
+
if (!attachments?.length) return [];
|
|
32014
|
+
const args = [];
|
|
32015
|
+
const siblingWire = siblingArgs.reduce((sum, arg) => sum + wireBytesOf(arg), 0);
|
|
32016
|
+
let attachmentWire = 0;
|
|
32017
|
+
let decodedTotal = 0;
|
|
32018
|
+
for (const attachment of attachments) {
|
|
32019
|
+
const { arg, bytes } = inlineFileArg(flag, attachment);
|
|
32020
|
+
attachmentWire += arg.contents.length;
|
|
32021
|
+
decodedTotal += bytes;
|
|
32022
|
+
if (siblingWire + attachmentWire > MAX_REQUEST_PAYLOAD_WIRE_BYTES) {
|
|
32023
|
+
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.` : "";
|
|
32024
|
+
throw new Error(
|
|
32025
|
+
`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.`
|
|
32026
|
+
);
|
|
32027
|
+
}
|
|
32028
|
+
args.push(arg);
|
|
32029
|
+
}
|
|
32030
|
+
return args;
|
|
32031
|
+
}
|
|
32032
|
+
|
|
31884
32033
|
// ../gogcli-mcp/src/gmail-results.ts
|
|
31885
32034
|
function sortKey(item) {
|
|
31886
32035
|
for (const raw of [item.internalDateIso, item.date]) {
|
|
@@ -31940,6 +32089,7 @@ async function fetchGmailPages(runPage, itemsKey, maxPages, startToken) {
|
|
|
31940
32089
|
const merged = [];
|
|
31941
32090
|
let base;
|
|
31942
32091
|
let token = startToken;
|
|
32092
|
+
const fetched = new Set(startToken === void 0 ? [] : [startToken]);
|
|
31943
32093
|
for (let pages = 0; pages < maxPages; pages++) {
|
|
31944
32094
|
const result = await runPage(token);
|
|
31945
32095
|
const parsed = parsePage(result, itemsKey);
|
|
@@ -31948,8 +32098,17 @@ async function fetchGmailPages(runPage, itemsKey, maxPages, startToken) {
|
|
|
31948
32098
|
}
|
|
31949
32099
|
base = parsed;
|
|
31950
32100
|
merged.push(...parsed[itemsKey]);
|
|
31951
|
-
|
|
31952
|
-
if (
|
|
32101
|
+
const next = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "" ? parsed.nextPageToken : void 0;
|
|
32102
|
+
if (next === void 0) {
|
|
32103
|
+
token = void 0;
|
|
32104
|
+
break;
|
|
32105
|
+
}
|
|
32106
|
+
if (fetched.has(next)) {
|
|
32107
|
+
token = next;
|
|
32108
|
+
break;
|
|
32109
|
+
}
|
|
32110
|
+
fetched.add(next);
|
|
32111
|
+
token = next;
|
|
31953
32112
|
}
|
|
31954
32113
|
return finish(base, itemsKey, merged, token);
|
|
31955
32114
|
}
|
|
@@ -32026,7 +32185,7 @@ function registerGmailTools(server) {
|
|
|
32026
32185
|
return runOrDiagnose(args, { account });
|
|
32027
32186
|
});
|
|
32028
32187
|
server.registerTool("gog_gmail_send", {
|
|
32029
|
-
description:
|
|
32188
|
+
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.',
|
|
32030
32189
|
annotations: { destructiveHint: true },
|
|
32031
32190
|
inputSchema: {
|
|
32032
32191
|
to: external_exports.string().describe("Recipient(s), comma-separated"),
|
|
@@ -32036,16 +32195,19 @@ function registerGmailTools(server) {
|
|
|
32036
32195
|
bcc: external_exports.string().optional().describe("BCC recipients, comma-separated"),
|
|
32037
32196
|
replyToMessageId: external_exports.string().optional().describe("Message ID to reply to"),
|
|
32038
32197
|
threadId: external_exports.string().optional().describe("Thread ID to reply within"),
|
|
32039
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
32198
|
+
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.`),
|
|
32199
|
+
attachInline: attachInlineParam,
|
|
32040
32200
|
account: accountParam
|
|
32041
32201
|
}
|
|
32042
|
-
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, account }) => {
|
|
32202
|
+
}, async ({ to, subject, body, cc, bcc, replyToMessageId, threadId, attach, attachInline, account }) => {
|
|
32043
32203
|
const args = ["gmail", "send", `--to=${to}`, `--subject=${subject}`, payloadArg("body", "body-file", body)];
|
|
32044
32204
|
if (cc) args.push(`--cc=${cc}`);
|
|
32045
32205
|
if (bcc) args.push(`--bcc=${bcc}`);
|
|
32046
32206
|
if (replyToMessageId) args.push(`--reply-to-message-id=${replyToMessageId}`);
|
|
32047
32207
|
if (threadId) args.push(`--thread-id=${threadId}`);
|
|
32048
32208
|
if (attach) for (const path of attach) args.push(`--attach=${path}`);
|
|
32209
|
+
const inline = inlineAttachmentArgs("attach", attachInline, args);
|
|
32210
|
+
args.push(...inline);
|
|
32049
32211
|
return runOrDiagnose(args, { account });
|
|
32050
32212
|
});
|
|
32051
32213
|
registerRunTool(server, { service: "gmail", examples: '"archive", "mark-read", "labels"' });
|
|
@@ -32061,7 +32223,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
|
32061
32223
|
);
|
|
32062
32224
|
|
|
32063
32225
|
// ../gogcli-mcp/src/server.ts
|
|
32064
|
-
var VERSION = true ? "2.
|
|
32226
|
+
var VERSION = true ? "2.26.0" : "0.0.0";
|
|
32065
32227
|
|
|
32066
32228
|
// ../gogcli-mcp/src/auth-log.ts
|
|
32067
32229
|
var FAILURES = /* @__PURE__ */ new Set([
|
|
@@ -32635,6 +32797,9 @@ var MAGIC_SIGNATURES = [
|
|
|
32635
32797
|
["\xFF\xD8\xFF", "image/jpeg"],
|
|
32636
32798
|
["GIF8", "image/gif"]
|
|
32637
32799
|
];
|
|
32800
|
+
function isValidBase642(value) {
|
|
32801
|
+
return Buffer.from(value, "base64").toString("base64") === value;
|
|
32802
|
+
}
|
|
32638
32803
|
function sniffMime(base643) {
|
|
32639
32804
|
const head = atob(base643.slice(0, 16));
|
|
32640
32805
|
for (const [signature, mimeType] of MAGIC_SIGNATURES) {
|
|
@@ -33571,8 +33736,15 @@ function registerExtraGmailTools(server) {
|
|
|
33571
33736
|
if (needInline) args.push("--inline");
|
|
33572
33737
|
args.push(`--inline-max-bytes=${inlineMaxBytes ?? GOG_DEFAULT_INLINE_MAX_BYTES}`);
|
|
33573
33738
|
args.push(`--out=${outPath}`, `--name=${filename ?? "attachment"}`);
|
|
33574
|
-
const info = JSON.parse(await run(args, { account }));
|
|
33739
|
+
const info = JSON.parse(await run(args, { account, opaqueFields: ["contentBase64"] }));
|
|
33575
33740
|
const path = info.path ?? outPath;
|
|
33741
|
+
const inlineUnusable = info.contentBase64 !== void 0 && !isValidBase642(info.contentBase64);
|
|
33742
|
+
if (inlineUnusable) {
|
|
33743
|
+
delete info.contentBase64;
|
|
33744
|
+
notes.push(
|
|
33745
|
+
"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."
|
|
33746
|
+
);
|
|
33747
|
+
}
|
|
33576
33748
|
if (!filename && info.filename) filename = sanitizeFilename(info.filename);
|
|
33577
33749
|
if (!mimeType && info.mimeType) mimeType = info.mimeType;
|
|
33578
33750
|
if (!filename && !indexed) {
|
|
@@ -33598,6 +33770,11 @@ function registerExtraGmailTools(server) {
|
|
|
33598
33770
|
if (info.contentBase64) {
|
|
33599
33771
|
return withNote(isImage ? inlineImageResult(summary, info.contentBase64, mimeType) : inlineResourceResult(messageId, filename, summary, info.contentBase64, mimeType), notes);
|
|
33600
33772
|
}
|
|
33773
|
+
if (inlineUnusable) {
|
|
33774
|
+
return errorResult(
|
|
33775
|
+
`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.`
|
|
33776
|
+
);
|
|
33777
|
+
}
|
|
33601
33778
|
return errorResult(
|
|
33602
33779
|
`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.`
|
|
33603
33780
|
);
|
|
@@ -34017,7 +34194,8 @@ function registerExtraGmailTools(server) {
|
|
|
34017
34194
|
replyTo: external_exports.string().optional().describe("Reply-To header address"),
|
|
34018
34195
|
quote: external_exports.boolean().optional().describe("Include quoted original message in reply (requires replyToMessageId or replyToThreadId)"),
|
|
34019
34196
|
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."),
|
|
34020
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
34197
|
+
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).`),
|
|
34198
|
+
attachInline: attachInlineParam,
|
|
34021
34199
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34022
34200
|
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."),
|
|
34023
34201
|
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."),
|
|
@@ -34041,6 +34219,7 @@ function registerExtraGmailTools(server) {
|
|
|
34041
34219
|
if (f.replyTo) args.push(`--reply-to=${f.replyTo}`);
|
|
34042
34220
|
if (f.quote) args.push("--quote");
|
|
34043
34221
|
if (f.attach) for (const path of f.attach) args.push(`--attach=${path}`);
|
|
34222
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
34044
34223
|
if (f.from) args.push(`--from=${f.from}`);
|
|
34045
34224
|
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
34046
34225
|
}
|
|
@@ -34212,7 +34391,8 @@ function registerExtraGmailTools(server) {
|
|
|
34212
34391
|
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."),
|
|
34213
34392
|
subject: external_exports.string().optional().describe('Override reply subject (default: "Re: <original>"). A changed subject starts a NEW Gmail thread.'),
|
|
34214
34393
|
noQuote: external_exports.boolean().optional().describe("Do not include the original message quoted below the reply (default: the original is quoted)"),
|
|
34215
|
-
attach: external_exports.array(external_exports.string()).optional().describe(
|
|
34394
|
+
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.`),
|
|
34395
|
+
attachInline: attachInlineParam,
|
|
34216
34396
|
from: external_exports.string().optional().describe("Send from this email address (must be a verified send-as alias)"),
|
|
34217
34397
|
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."),
|
|
34218
34398
|
signature: external_exports.boolean().optional().describe("Append the Gmail signature from the active send-as address"),
|
|
@@ -34232,6 +34412,7 @@ function registerExtraGmailTools(server) {
|
|
|
34232
34412
|
if (f.subject) args.push(`--subject=${f.subject}`);
|
|
34233
34413
|
if (f.noQuote) args.push("--no-quote");
|
|
34234
34414
|
if (f.attach) for (const p of f.attach) args.push(`--attach=${p}`);
|
|
34415
|
+
args.push(...inlineAttachmentArgs("attach", f.attachInline, args));
|
|
34235
34416
|
if (f.from) args.push(`--from=${f.from}`);
|
|
34236
34417
|
if (f.signature) args.push("--signature");
|
|
34237
34418
|
if (f.signatureFrom) args.push(`--signature-from=${f.signatureFrom}`);
|
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.26.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",
|
|
@@ -215,11 +215,11 @@
|
|
|
215
215
|
},
|
|
216
216
|
{
|
|
217
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)"
|
|
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
219
|
},
|
|
220
220
|
{
|
|
221
221
|
"name": "gog_gmail_drafts_reply_all",
|
|
222
|
-
"description": "Save a reply-all to a Gmail message as a draft (never sends)"
|
|
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
223
|
},
|
|
224
224
|
{
|
|
225
225
|
"name": "gog_gmail_drafts_forward",
|
|
@@ -235,11 +235,11 @@
|
|
|
235
235
|
},
|
|
236
236
|
{
|
|
237
237
|
"name": "gog_gmail_reply",
|
|
238
|
-
"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)."
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
"name": "gog_gmail_reply_all",
|
|
242
|
-
"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)."
|
|
243
243
|
},
|
|
244
244
|
{
|
|
245
245
|
"name": "gog_gmail_autoreply",
|
package/mint.yaml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
name: Google Gmail (gogcli)
|
|
3
|
+
slug: gogcli-mcp-gmail
|
|
4
|
+
summary: >-
|
|
5
|
+
Extended Gmail MCP server via gogcli — auth + full Gmail support (threads,
|
|
6
|
+
labels, drafts, attachments, forward, autoreply, bulk operations)
|
|
7
|
+
command:
|
|
8
|
+
# This package publishes a single bin; naming it keeps the install
|
|
9
|
+
# unambiguous alongside its eight sibling packages.
|
|
10
|
+
bin: gogcli-mcp-gmail
|
|
11
|
+
env:
|
|
12
|
+
- name: GOG_CLIENT_ID
|
|
13
|
+
required: false
|
|
14
|
+
help: >-
|
|
15
|
+
Google OAuth client id. NOTE: on the local-spawn path the child `gog`
|
|
16
|
+
receives a sanitized env — runner.ts drops GOG_ACCESS_TOKEN and every
|
|
17
|
+
*_TOKEN / *_SECRET / *_API_KEY / *_PRIVATE_KEY variable — so the CLI
|
|
18
|
+
authenticates from its own stored credentials under $HOME (see
|
|
19
|
+
state.dataDir), not from these variables being passed through.
|
|
20
|
+
- name: GOG_CLIENT_SECRET
|
|
21
|
+
secret: true
|
|
22
|
+
required: false
|
|
23
|
+
help: >-
|
|
24
|
+
Google OAuth client secret. Stripped from the spawned CLI's environment
|
|
25
|
+
by runner.ts's *_SECRET rule — see GOG_CLIENT_ID.
|
|
26
|
+
- name: GOG_REFRESH_TOKEN
|
|
27
|
+
secret: true
|
|
28
|
+
required: false
|
|
29
|
+
help: >-
|
|
30
|
+
Google OAuth refresh token. Stripped from the spawned CLI's environment
|
|
31
|
+
by runner.ts's *_TOKEN rule — see GOG_CLIENT_ID. A hosted deployment must
|
|
32
|
+
therefore carry gog's authorised-account state in its persisted data dir,
|
|
33
|
+
or drive a remote runner via GOG_RUNNER_URL.
|
|
34
|
+
- name: GOG_ACCESS_TOKEN
|
|
35
|
+
secret: true
|
|
36
|
+
required: false
|
|
37
|
+
help: >-
|
|
38
|
+
Deliberately removed from the spawned CLI's environment by runner.ts, so
|
|
39
|
+
that a stale directly-passed token cannot shadow gog's stored refresh
|
|
40
|
+
credential. Leave unset.
|
|
41
|
+
- name: GOG_ACCOUNT
|
|
42
|
+
required: false
|
|
43
|
+
help: >-
|
|
44
|
+
Which configured Google account to act as, when more than one is
|
|
45
|
+
authorised. Defaults to the single/most recent account.
|
|
46
|
+
- name: GOG_READONLY
|
|
47
|
+
required: false
|
|
48
|
+
help: >-
|
|
49
|
+
Set to 1 to refuse every mutating operation. Recommended when the
|
|
50
|
+
connector is shared or you only need reads.
|
|
51
|
+
- name: GOG_PATH
|
|
52
|
+
required: false
|
|
53
|
+
help: >-
|
|
54
|
+
Path to the `gog` binary. Leave unset when the dependency below supplies
|
|
55
|
+
it; set it only to point at a binary you manage yourself.
|
|
56
|
+
- name: GOG_RUNNER_URL
|
|
57
|
+
required: false
|
|
58
|
+
help: >-
|
|
59
|
+
URL of a remote gog runner to execute against instead of spawning the
|
|
60
|
+
local binary. If you set this, add its host to egress.allow.
|
|
61
|
+
- name: GOG_RUNNER_KEY
|
|
62
|
+
secret: true
|
|
63
|
+
required: false
|
|
64
|
+
help: >-
|
|
65
|
+
Shared key authenticating calls to GOG_RUNNER_URL. Required whenever that
|
|
66
|
+
is set.
|
|
67
|
+
- name: GOG_TIMEZONE
|
|
68
|
+
required: false
|
|
69
|
+
help: >-
|
|
70
|
+
The IANA zone gog itself formats its naive timestamps in. The wrapper
|
|
71
|
+
reads it (naiveSourceTimeZone) to re-attach the correct offset, so it
|
|
72
|
+
should match gog's own configuration. Falls back to DISPLAY_TZ, then to
|
|
73
|
+
America/New_York.
|
|
74
|
+
- name: DISPLAY_TZ
|
|
75
|
+
required: false
|
|
76
|
+
help: >-
|
|
77
|
+
The IANA zone every rendered *Display field uses (displayTimeZone,
|
|
78
|
+
default America/New_York). It does not read GOG_TIMEZONE — the fallback
|
|
79
|
+
runs the other way, so this is also what GOG_TIMEZONE falls back to. An
|
|
80
|
+
invalid value degrades to the default rather than throwing.
|
|
81
|
+
dependencies:
|
|
82
|
+
# Every tool shells out to the `gog` CLI; without it the server starts and
|
|
83
|
+
# then fails on the first call. This tag must track
|
|
84
|
+
# packages/gogcli-mcp/src/runner.ts's MIN_GOG_VERSION (the floor the tools
|
|
85
|
+
# assume) and the fly-gog-runner/Dockerfile GOG_VERSION build arg. See
|
|
86
|
+
# CLAUDE.md "Required gog version" — bumping the floor means bumping these
|
|
87
|
+
# nine pins too.
|
|
88
|
+
- kind: github-release
|
|
89
|
+
repo: openclaw/gogcli
|
|
90
|
+
tag: v0.38.1
|
|
91
|
+
asset: "gogcli_*_linux_amd64.tar.gz"
|
|
92
|
+
bin: [gog]
|
|
93
|
+
state:
|
|
94
|
+
dataDir: true
|
|
95
|
+
reason: >-
|
|
96
|
+
`gog` keeps its authorised-account state and token cache under $HOME.
|
|
97
|
+
Without a persistent data dir every cold start has no account configured and
|
|
98
|
+
every tool call fails until the credentials are re-supplied.
|
|
99
|
+
egress:
|
|
100
|
+
allow:
|
|
101
|
+
# Google OAuth token exchange and the Google REST APIs the CLI calls.
|
|
102
|
+
- oauth2.googleapis.com
|
|
103
|
+
- www.googleapis.com
|
|
104
|
+
- googleapis.com
|
|
105
|
+
#
|
|
106
|
+
# NOTE: if you set GOG_RUNNER_URL to run against a remote gog runner, add
|
|
107
|
+
# that host here too — it is supplied at runtime and cannot be declared.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.26.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>",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"test:coverage": "vitest run --coverage"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@chrischall/mcp-utils": "^0.
|
|
27
|
+
"@chrischall/mcp-utils": "^0.15.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
29
29
|
"zod": "^4.4.3"
|
|
30
30
|
},
|
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}`);
|
|
@@ -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',
|