gogcli-mcp-gmail 2.23.0 → 2.23.2
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
CHANGED
|
@@ -31547,6 +31547,40 @@ function normalizeTimestamps(text, tz = displayTimeZone(), naiveTz = naiveSource
|
|
|
31547
31547
|
return JSON.stringify(parsed, null, detectIndent(text));
|
|
31548
31548
|
}
|
|
31549
31549
|
|
|
31550
|
+
// ../gogcli-mcp/src/pagination.ts
|
|
31551
|
+
function detectIndent2(text) {
|
|
31552
|
+
const match = /\n(\s+)\S/.exec(text);
|
|
31553
|
+
return match ? match[1].replace(/\t/g, " ").length : 0;
|
|
31554
|
+
}
|
|
31555
|
+
function stripConsumedPageToken(text) {
|
|
31556
|
+
const trimmed = text.trim();
|
|
31557
|
+
if (trimmed === "" || !trimmed.startsWith("{")) return text;
|
|
31558
|
+
let parsed;
|
|
31559
|
+
try {
|
|
31560
|
+
parsed = JSON.parse(trimmed);
|
|
31561
|
+
} catch {
|
|
31562
|
+
return text;
|
|
31563
|
+
}
|
|
31564
|
+
const obj = parsed;
|
|
31565
|
+
if (obj.nextPageToken !== "") return text;
|
|
31566
|
+
delete obj.nextPageToken;
|
|
31567
|
+
return JSON.stringify(obj, null, detectIndent2(text));
|
|
31568
|
+
}
|
|
31569
|
+
function truncationWarning(returned, count) {
|
|
31570
|
+
const scope = count.total !== void 0 ? `returned ${returned} of ${count.total} matches` : count.atLeast !== void 0 ? `returned ${returned} of at least ${count.atLeast} matches` : `returned ${returned} matches and MORE EXIST beyond this page`;
|
|
31571
|
+
return `INCOMPLETE RESULT SET: ${scope}. Do not report an absence of results based on this response. Page with nextPageToken or narrow the query.`;
|
|
31572
|
+
}
|
|
31573
|
+
function annotateTruncation(out, returned, count) {
|
|
31574
|
+
out.truncated = true;
|
|
31575
|
+
out.returned = returned;
|
|
31576
|
+
if (count.total !== void 0) out.totalMatches = count.total;
|
|
31577
|
+
if (count.atLeast !== void 0) out.totalMatchesAtLeast = count.atLeast;
|
|
31578
|
+
out.warning = truncationWarning(returned, count);
|
|
31579
|
+
}
|
|
31580
|
+
function hasMorePages(parsed) {
|
|
31581
|
+
return typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "";
|
|
31582
|
+
}
|
|
31583
|
+
|
|
31550
31584
|
// ../gogcli-mcp/src/tools/utils.ts
|
|
31551
31585
|
var PAYLOAD_INLINE_MAX = 4096;
|
|
31552
31586
|
function payloadArg(inlineFlag, fileFlag, value, ext) {
|
|
@@ -31582,9 +31616,19 @@ var ids = {
|
|
|
31582
31616
|
// People API uses fully-qualified resource names ("people/c123") not bare IDs.
|
|
31583
31617
|
person: external_exports.string().describe("Person resource name (people/...) or email")
|
|
31584
31618
|
};
|
|
31619
|
+
var pageTokenParam = external_exports.string().optional().describe(
|
|
31620
|
+
"Cursor for the NEXT page. Pass back the nextPageToken from a previous response verbatim, keeping the query and max identical: call once, then call again with pageToken=<that value>. A response with NO nextPageToken is the last page."
|
|
31621
|
+
);
|
|
31622
|
+
var pageAliasParam = external_exports.string().optional().describe(
|
|
31623
|
+
"Deprecated alias for pageToken, accepted so existing callers keep working. Use pageToken \u2014 it matches the nextPageToken field in the response."
|
|
31624
|
+
);
|
|
31625
|
+
function resolvePageToken(p) {
|
|
31626
|
+
return p.pageToken ?? p.page;
|
|
31627
|
+
}
|
|
31585
31628
|
var paginationParams = {
|
|
31586
31629
|
max: external_exports.number().int().optional().describe("Max results"),
|
|
31587
|
-
|
|
31630
|
+
pageToken: pageTokenParam,
|
|
31631
|
+
page: pageAliasParam,
|
|
31588
31632
|
all: external_exports.boolean().optional().describe("Fetch all pages")
|
|
31589
31633
|
};
|
|
31590
31634
|
function registerRunTool(server, options) {
|
|
@@ -31659,7 +31703,7 @@ ${accounts || "(none)"}${hint}`);
|
|
|
31659
31703
|
async function runOrDiagnose(args, options) {
|
|
31660
31704
|
try {
|
|
31661
31705
|
const raw = await run(args, options);
|
|
31662
|
-
return rawTextResult(options.lossless ? raw : normalizeTimestamps(raw));
|
|
31706
|
+
return rawTextResult(options.lossless ? raw : stripConsumedPageToken(normalizeTimestamps(raw)));
|
|
31663
31707
|
} catch (err) {
|
|
31664
31708
|
return diagnose(err);
|
|
31665
31709
|
}
|
|
@@ -31830,22 +31874,130 @@ function authToolsFor(defaultServices) {
|
|
|
31830
31874
|
return (server) => registerAuthToolsWith(server, defaultServices);
|
|
31831
31875
|
}
|
|
31832
31876
|
|
|
31877
|
+
// ../gogcli-mcp/src/gmail-results.ts
|
|
31878
|
+
function sortKey(item) {
|
|
31879
|
+
for (const raw of [item.internalDateIso, item.date]) {
|
|
31880
|
+
if (typeof raw !== "string" || !raw) continue;
|
|
31881
|
+
const t = Date.parse(raw);
|
|
31882
|
+
if (!Number.isNaN(t)) return t;
|
|
31883
|
+
}
|
|
31884
|
+
return Number.NEGATIVE_INFINITY;
|
|
31885
|
+
}
|
|
31886
|
+
function sortNewestFirst(items) {
|
|
31887
|
+
return [...items].sort((a, b) => {
|
|
31888
|
+
const ka = sortKey(a);
|
|
31889
|
+
const kb = sortKey(b);
|
|
31890
|
+
return ka === kb ? 0 : kb - ka;
|
|
31891
|
+
});
|
|
31892
|
+
}
|
|
31893
|
+
var COUNT_PROBE_PAGE_SIZE = 500;
|
|
31894
|
+
async function countMatches(method, itemsKey, query, account) {
|
|
31895
|
+
try {
|
|
31896
|
+
const params = JSON.stringify({
|
|
31897
|
+
userId: "me",
|
|
31898
|
+
q: query,
|
|
31899
|
+
maxResults: COUNT_PROBE_PAGE_SIZE,
|
|
31900
|
+
fields: `${itemsKey}/id,nextPageToken`
|
|
31901
|
+
});
|
|
31902
|
+
const raw = await run(["api", "call", "gmail", "v1", method, `--params=${params}`], { account });
|
|
31903
|
+
const parsed = JSON.parse(raw);
|
|
31904
|
+
const items = parsed[itemsKey];
|
|
31905
|
+
if (!Array.isArray(items)) return {};
|
|
31906
|
+
const more = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "";
|
|
31907
|
+
return more ? { atLeast: items.length } : { total: items.length };
|
|
31908
|
+
} catch {
|
|
31909
|
+
return {};
|
|
31910
|
+
}
|
|
31911
|
+
}
|
|
31912
|
+
async function finalizeGmailSearch(result, options) {
|
|
31913
|
+
const { itemsKey, method, query, account, queryIsExact = true } = options;
|
|
31914
|
+
const first = result.content[0];
|
|
31915
|
+
if (result.isError || first?.type !== "text") return result;
|
|
31916
|
+
let parsed;
|
|
31917
|
+
try {
|
|
31918
|
+
parsed = JSON.parse(first.text);
|
|
31919
|
+
} catch {
|
|
31920
|
+
return result;
|
|
31921
|
+
}
|
|
31922
|
+
const items = parsed[itemsKey];
|
|
31923
|
+
if (!Array.isArray(items)) return result;
|
|
31924
|
+
const sorted = sortNewestFirst(items);
|
|
31925
|
+
const out = { ...parsed, [itemsKey]: sorted };
|
|
31926
|
+
if (hasMorePages(parsed)) {
|
|
31927
|
+
const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
|
|
31928
|
+
annotateTruncation(out, sorted.length, count);
|
|
31929
|
+
}
|
|
31930
|
+
return rawTextResult(JSON.stringify(out));
|
|
31931
|
+
}
|
|
31932
|
+
async function fetchGmailPages(runPage, itemsKey, maxPages, startToken) {
|
|
31933
|
+
const merged = [];
|
|
31934
|
+
let base;
|
|
31935
|
+
let token = startToken;
|
|
31936
|
+
for (let pages = 0; pages < maxPages; pages++) {
|
|
31937
|
+
const result = await runPage(token);
|
|
31938
|
+
const parsed = parsePage(result, itemsKey);
|
|
31939
|
+
if (parsed === void 0) {
|
|
31940
|
+
return base === void 0 ? result : finish(base, itemsKey, merged, token);
|
|
31941
|
+
}
|
|
31942
|
+
base = parsed;
|
|
31943
|
+
merged.push(...parsed[itemsKey]);
|
|
31944
|
+
token = typeof parsed.nextPageToken === "string" && parsed.nextPageToken !== "" ? parsed.nextPageToken : void 0;
|
|
31945
|
+
if (token === void 0) break;
|
|
31946
|
+
}
|
|
31947
|
+
return finish(base, itemsKey, merged, token);
|
|
31948
|
+
}
|
|
31949
|
+
function parsePage(result, itemsKey) {
|
|
31950
|
+
const first = result.content[0];
|
|
31951
|
+
if (result.isError || first?.type !== "text") return void 0;
|
|
31952
|
+
let parsed;
|
|
31953
|
+
try {
|
|
31954
|
+
parsed = JSON.parse(first.text);
|
|
31955
|
+
} catch {
|
|
31956
|
+
return void 0;
|
|
31957
|
+
}
|
|
31958
|
+
if (parsed === null || typeof parsed !== "object") return void 0;
|
|
31959
|
+
const obj = parsed;
|
|
31960
|
+
return Array.isArray(obj[itemsKey]) ? obj : void 0;
|
|
31961
|
+
}
|
|
31962
|
+
function finish(base, itemsKey, merged, token) {
|
|
31963
|
+
const out = { ...base, [itemsKey]: merged };
|
|
31964
|
+
if (token === void 0) delete out.nextPageToken;
|
|
31965
|
+
else out.nextPageToken = token;
|
|
31966
|
+
return rawTextResult(JSON.stringify(out));
|
|
31967
|
+
}
|
|
31968
|
+
|
|
31833
31969
|
// ../gogcli-mcp/src/tools/gmail.ts
|
|
31834
31970
|
function registerGmailTools(server) {
|
|
31835
31971
|
server.registerTool("gog_gmail_search", {
|
|
31836
|
-
description:
|
|
31972
|
+
description: 'Search Gmail threads using Gmail query syntax (e.g. "from:alice subject:invoice is:unread"). The query is passed verbatim to Gmail; a bare name token (from:alison) matches per Gmail\'s own heuristics, a full address (from:alison@example.com) is exact. To match a contact across several addresses, OR them: from:(a@x.com OR b@y.com). Results are ALWAYS newest-first by Gmail\'s internalDate \u2014 the wrapper sorts them, so the first result is the most recent match and a recent message can never be buried below older ones. IMPORTANT \u2014 a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist, or that there is no such mail, on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query, and only then draw a conclusion. If you already know the thread, do not search for it at all \u2014 read it directly with gog_gmail_thread_get, which returns the whole thread and cannot be truncated or mis-ranked.',
|
|
31837
31973
|
annotations: { readOnlyHint: true },
|
|
31838
31974
|
inputSchema: {
|
|
31839
31975
|
query: external_exports.string().describe("Gmail search query"),
|
|
31840
31976
|
max: external_exports.number().int().optional().describe("Max results to return (default: 10)"),
|
|
31977
|
+
pageToken: pageTokenParam,
|
|
31978
|
+
page: pageAliasParam,
|
|
31979
|
+
maxPages: external_exports.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions ("is there any mail matching X?"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
31980
|
+
all: external_exports.boolean().optional().describe('Fetch every page instead of one. Removes truncation entirely, at the cost of one API round-trip per page \u2014 the reliable way to answer "does any message match?" for a query with few expected hits.'),
|
|
31841
31981
|
fromContact: external_exports.string().optional().describe("Resolve a Google Contact (name or email) to its addresses and AND a from:(addr OR addr) clause onto the query \u2014 saves looking the contact up first when you only know who, not which address."),
|
|
31842
31982
|
account: accountParam
|
|
31843
31983
|
}
|
|
31844
|
-
}, async ({ query, max, fromContact, account }) => {
|
|
31984
|
+
}, async ({ query, max, pageToken, page, maxPages, all, fromContact, account }) => {
|
|
31845
31985
|
const args = ["gmail", "search", query];
|
|
31846
31986
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
31987
|
+
if (all) args.push("--all");
|
|
31847
31988
|
if (fromContact) args.push(`--from-contact=${fromContact}`);
|
|
31848
|
-
|
|
31989
|
+
const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
31990
|
+
const token = resolvePageToken({ pageToken, page });
|
|
31991
|
+
const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "threads", maxPages, token) : await runPage(token);
|
|
31992
|
+
return finalizeGmailSearch(result, {
|
|
31993
|
+
itemsKey: "threads",
|
|
31994
|
+
method: "users.threads.list",
|
|
31995
|
+
query,
|
|
31996
|
+
account,
|
|
31997
|
+
// --from-contact is expanded INSIDE gog, against the People API, so the
|
|
31998
|
+
// query Gmail actually saw is not the one we hold here.
|
|
31999
|
+
queryIsExact: !fromContact
|
|
32000
|
+
});
|
|
31849
32001
|
});
|
|
31850
32002
|
server.registerTool("gog_gmail_get", {
|
|
31851
32003
|
description: "Get a Gmail message by ID.",
|
|
@@ -31896,7 +32048,7 @@ var failIfNotEmptyParam = external_exports.boolean().optional().describe(
|
|
|
31896
32048
|
);
|
|
31897
32049
|
|
|
31898
32050
|
// ../gogcli-mcp/src/server.ts
|
|
31899
|
-
var VERSION = true ? "2.23.
|
|
32051
|
+
var VERSION = true ? "2.23.2" : "0.0.0";
|
|
31900
32052
|
|
|
31901
32053
|
// ../gogcli-mcp/src/auth-log.ts
|
|
31902
32054
|
var FAILURES = /* @__PURE__ */ new Set([
|
|
@@ -33464,15 +33616,17 @@ function registerExtraGmailTools(server) {
|
|
|
33464
33616
|
inputSchema: {
|
|
33465
33617
|
since: external_exports.string().optional().describe("Start history ID"),
|
|
33466
33618
|
max: external_exports.number().optional().describe("Max results (default: 100)"),
|
|
33467
|
-
|
|
33619
|
+
pageToken: pageTokenParam,
|
|
33620
|
+
page: pageAliasParam,
|
|
33468
33621
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
33469
33622
|
account: accountParam
|
|
33470
33623
|
}
|
|
33471
|
-
}, async ({ since, max, page, all, account }) => {
|
|
33624
|
+
}, async ({ since, max, pageToken, page, all, account }) => {
|
|
33472
33625
|
const args = ["gmail", "history"];
|
|
33473
33626
|
if (since) args.push(`--since=${since}`);
|
|
33474
33627
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
33475
|
-
|
|
33628
|
+
const token = resolvePageToken({ pageToken, page });
|
|
33629
|
+
if (token) args.push(`--page=${token}`);
|
|
33476
33630
|
if (all) args.push("--all");
|
|
33477
33631
|
return runOrDiagnose(args, { account });
|
|
33478
33632
|
});
|
|
@@ -33567,7 +33721,7 @@ function registerExtraGmailTools(server) {
|
|
|
33567
33721
|
return runOrDiagnose(args, { account });
|
|
33568
33722
|
});
|
|
33569
33723
|
server.registerTool("gog_gmail_thread_get", {
|
|
33570
|
-
description: "Get a Gmail thread with all messages. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id \u2014 pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<\u2026@host>` value used in In-Reply-To/References) \u2014 don't confuse either with the `threadId`. To reply to the thread itself, pass the thread's id as replyToThreadId on gog_gmail_drafts_create.",
|
|
33724
|
+
description: "Get a Gmail thread with all messages. THIS IS THE CORRECT TOOL WHEN YOU ALREADY KNOW THE threadId \u2014 it returns the thread in full, so unlike a search it can never be truncated, mis-ranked, or come back empty because the query missed. Never re-discover a known thread with gog_gmail_search; read it here. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id \u2014 pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<\u2026@host>` value used in In-Reply-To/References) \u2014 don't confuse either with the `threadId`. To reply to the thread itself, pass the thread's id as replyToThreadId on gog_gmail_drafts_create.",
|
|
33571
33725
|
annotations: { readOnlyHint: true },
|
|
33572
33726
|
inputSchema: {
|
|
33573
33727
|
threadId: external_exports.string().describe("Gmail thread ID"),
|
|
@@ -33692,17 +33846,19 @@ function registerExtraGmailTools(server) {
|
|
|
33692
33846
|
annotations: { readOnlyHint: true },
|
|
33693
33847
|
inputSchema: {
|
|
33694
33848
|
max: external_exports.number().optional().describe("Max results (default: 20)"),
|
|
33695
|
-
|
|
33849
|
+
pageToken: pageTokenParam,
|
|
33850
|
+
page: pageAliasParam,
|
|
33696
33851
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
33697
33852
|
enrich: external_exports.boolean().optional().describe(
|
|
33698
33853
|
"Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) regardless of how many drafts there are \u2014 but that single command makes gog fetch every matching draft server-side at concurrency 10, so Google reads and wall-clock are linear in the result count even though gog spawns are not. Narrow `max` before enabling it. If the search fails the listing silently degrades to the free fields rather than erroring."
|
|
33699
33854
|
),
|
|
33700
33855
|
account: accountParam
|
|
33701
33856
|
}
|
|
33702
|
-
}, async ({ max, page, all, enrich, account }) => {
|
|
33857
|
+
}, async ({ max, pageToken, page, all, enrich, account }) => {
|
|
33703
33858
|
const args = ["gmail", "drafts", "list"];
|
|
33704
33859
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
33705
|
-
|
|
33860
|
+
const token = resolvePageToken({ pageToken, page });
|
|
33861
|
+
if (token) args.push(`--page=${token}`);
|
|
33706
33862
|
if (all) args.push("--all");
|
|
33707
33863
|
const result = await runOrDiagnose(args, { account });
|
|
33708
33864
|
let parsed;
|
|
@@ -33720,7 +33876,7 @@ function registerExtraGmailTools(server) {
|
|
|
33720
33876
|
if (enrich) {
|
|
33721
33877
|
const searchArgs = ["gmail", "messages", "search", "in:drafts", `--max=${max ?? GOG_DRAFTS_LIST_DEFAULT_MAX}`];
|
|
33722
33878
|
if (all) searchArgs.push("--all");
|
|
33723
|
-
if (
|
|
33879
|
+
if (token) searchArgs.push(`--page=${token}`);
|
|
33724
33880
|
searchArgs.push("--include-attachments=false", "--use-indexed-attachment-ids=false");
|
|
33725
33881
|
try {
|
|
33726
33882
|
const messages = JSON.parse(await runNormalized(searchArgs, { account })).messages;
|
|
@@ -33875,14 +34031,17 @@ function registerExtraGmailTools(server) {
|
|
|
33875
34031
|
if (f.from) args.push(`--from=${f.from}`);
|
|
33876
34032
|
args.push(f.autoFromAddressedAlias ? "--auto-from-addressed-alias" : "--auto-from-addressed-alias=false");
|
|
33877
34033
|
}
|
|
33878
|
-
function withRefetchNote(written, draftId) {
|
|
34034
|
+
function withRefetchNote(written, draftId, refetch) {
|
|
34035
|
+
const detail = resultText(refetch)?.trim();
|
|
34036
|
+
const looksForked = detail !== void 0 && DRAFT_NOT_FOUND_PATTERN.test(detail);
|
|
34037
|
+
const because = looksForked ? "the id did not resolve, which on this mailbox usually means the draft was forked by a mail client between the write and the read. Run gog_gmail_drafts_list to find the current id" : `the read failed for a different reason, reported verbatim here: ${detail ?? "(no detail supplied)"}. That is a failure of the READ ONLY`;
|
|
33879
34038
|
return {
|
|
33880
34039
|
...written,
|
|
33881
34040
|
content: [
|
|
33882
34041
|
...written.content,
|
|
33883
34042
|
{
|
|
33884
34043
|
type: "text",
|
|
33885
|
-
text: `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up read-back requested by returnFull could not be performed \u2014
|
|
34044
|
+
text: `Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up read-back requested by returnFull could not be performed \u2014 ${because}. Nothing was lost; run gog_gmail_drafts_get on ${draftId} to confirm the saved content.`
|
|
33886
34045
|
}
|
|
33887
34046
|
]
|
|
33888
34047
|
};
|
|
@@ -33904,7 +34063,7 @@ function registerExtraGmailTools(server) {
|
|
|
33904
34063
|
if (draftId) {
|
|
33905
34064
|
const refetched = await runOrDiagnose(["gmail", "drafts", "get", draftId, "--use-indexed-attachment-ids=false"], { account });
|
|
33906
34065
|
if (refetched.isError !== true) final = refetched;
|
|
33907
|
-
else final = withRefetchNote(result, draftId);
|
|
34066
|
+
else final = withRefetchNote(result, draftId, refetched);
|
|
33908
34067
|
}
|
|
33909
34068
|
}
|
|
33910
34069
|
if (!verification) return final;
|
|
@@ -34118,12 +34277,14 @@ function registerExtraGmailTools(server) {
|
|
|
34118
34277
|
return runOrDiagnose(args, { account });
|
|
34119
34278
|
});
|
|
34120
34279
|
server.registerTool("gog_gmail_messages_search", {
|
|
34121
|
-
description:
|
|
34280
|
+
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message. Results are ALWAYS newest-first by Gmail\'s internalDate \u2014 the wrapper sorts them, so the first result is the most recent match. IMPORTANT \u2014 a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query first. If you already know the thread, read it with gog_gmail_thread_get instead of searching for it.',
|
|
34122
34281
|
annotations: { readOnlyHint: true },
|
|
34123
34282
|
inputSchema: {
|
|
34124
34283
|
query: external_exports.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
|
|
34125
34284
|
max: external_exports.number().optional().describe("Max results"),
|
|
34126
|
-
|
|
34285
|
+
pageToken: pageTokenParam,
|
|
34286
|
+
page: pageAliasParam,
|
|
34287
|
+
maxPages: external_exports.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions ("is there any mail matching X?"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
34127
34288
|
all: external_exports.boolean().optional().describe("Fetch all pages"),
|
|
34128
34289
|
includeBody: external_exports.boolean().optional().describe("Include the decoded message body in each result"),
|
|
34129
34290
|
full: external_exports.boolean().optional().describe("Show full message bodies without truncation (implies includeBody)"),
|
|
@@ -34132,17 +34293,24 @@ function registerExtraGmailTools(server) {
|
|
|
34132
34293
|
useIndexedAttachmentIds: external_exports.boolean().optional().describe("Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody."),
|
|
34133
34294
|
account: accountParam
|
|
34134
34295
|
}
|
|
34135
|
-
}, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
34296
|
+
}, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
34136
34297
|
const args = ["gmail", "messages", "search", query];
|
|
34137
34298
|
if (max !== void 0) args.push(`--max=${max}`);
|
|
34138
|
-
if (page) args.push(`--page=${page}`);
|
|
34139
34299
|
if (all) args.push("--all");
|
|
34140
34300
|
if (includeBody) args.push("--include-body");
|
|
34141
34301
|
if (full) args.push("--full");
|
|
34142
34302
|
if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
|
|
34143
34303
|
args.push(includeAttachments ? "--include-attachments" : "--include-attachments=false");
|
|
34144
34304
|
args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
|
|
34145
|
-
|
|
34305
|
+
const runPage = (tok) => runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
34306
|
+
const token = resolvePageToken({ pageToken, page });
|
|
34307
|
+
const result = maxPages !== void 0 ? await fetchGmailPages(runPage, "messages", maxPages, token) : await runPage(token);
|
|
34308
|
+
return finalizeGmailSearch(result, {
|
|
34309
|
+
itemsKey: "messages",
|
|
34310
|
+
method: "users.messages.list",
|
|
34311
|
+
query,
|
|
34312
|
+
account
|
|
34313
|
+
});
|
|
34146
34314
|
});
|
|
34147
34315
|
server.registerTool("gog_gmail_labels_style", {
|
|
34148
34316
|
description: "Change a user label's color or visibility (background/text color from Gmail's palette, label-list and message-list visibility).",
|
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.23.
|
|
6
|
+
"version": "2.23.2",
|
|
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",
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
{
|
|
93
93
|
"name": "gog_gmail_search",
|
|
94
|
-
"description": "Search Gmail threads using Gmail query syntax"
|
|
94
|
+
"description": "Search Gmail threads using Gmail query syntax; newest-first, and flags a truncated result set"
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"name": "gog_gmail_get",
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"name": "gog_gmail_thread_get",
|
|
154
|
-
"description": "Get a thread with all messages"
|
|
154
|
+
"description": "Get a Gmail thread with all messages — the right tool when the threadId is already known"
|
|
155
155
|
},
|
|
156
156
|
{
|
|
157
157
|
"name": "gog_gmail_thread_modify",
|
|
@@ -235,7 +235,7 @@
|
|
|
235
235
|
},
|
|
236
236
|
{
|
|
237
237
|
"name": "gog_gmail_messages_search",
|
|
238
|
-
"description": "Search individual messages
|
|
238
|
+
"description": "Search individual messages; newest-first, and flags a truncated result set"
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
"name": "gog_gmail_labels_style",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gogcli-mcp-gmail",
|
|
3
|
-
"version": "2.23.
|
|
3
|
+
"version": "2.23.2",
|
|
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,7 +2,7 @@ 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 } from '../../../gogcli-mcp/src/lib.js';
|
|
5
|
+
import { accountParam, runOrDiagnose, run, diagnose, payloadArg, runExecutor, normalizeTimestamps, finalizeGmailSearch, fetchGmailPages, pageTokenParam, pageAliasParam, resolvePageToken} from '../../../gogcli-mcp/src/lib.js';
|
|
6
6
|
import type { GogArg } from '../../../gogcli-mcp/src/lib.js';
|
|
7
7
|
|
|
8
8
|
// gog rejects an inline flag together with its --*-file twin — `gmail drafts
|
|
@@ -2365,15 +2365,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2365
2365
|
inputSchema: {
|
|
2366
2366
|
since: z.string().optional().describe('Start history ID'),
|
|
2367
2367
|
max: z.number().optional().describe('Max results (default: 100)'),
|
|
2368
|
-
|
|
2368
|
+
pageToken: pageTokenParam,
|
|
2369
|
+
page: pageAliasParam,
|
|
2369
2370
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
2370
2371
|
account: accountParam,
|
|
2371
2372
|
},
|
|
2372
|
-
}, async ({ since, max, page, all, account }) => {
|
|
2373
|
+
}, async ({ since, max, pageToken, page, all, account }) => {
|
|
2373
2374
|
const args = ['gmail', 'history'];
|
|
2374
2375
|
if (since) args.push(`--since=${since}`);
|
|
2375
2376
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
2376
|
-
|
|
2377
|
+
const token = resolvePageToken({ pageToken, page });
|
|
2378
|
+
if (token) args.push(`--page=${token}`);
|
|
2377
2379
|
if (all) args.push('--all');
|
|
2378
2380
|
return runOrDiagnose(args, { account });
|
|
2379
2381
|
});
|
|
@@ -2476,7 +2478,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2476
2478
|
});
|
|
2477
2479
|
|
|
2478
2480
|
server.registerTool('gog_gmail_thread_get', {
|
|
2479
|
-
description: 'Get a Gmail thread with all messages. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id — pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<…@host>` value used in In-Reply-To/References) — don\'t confuse either with the `threadId`. To reply to the thread itself, pass the thread\'s id as replyToThreadId on gog_gmail_drafts_create.',
|
|
2481
|
+
description: 'Get a Gmail thread with all messages. THIS IS THE CORRECT TOOL WHEN YOU ALREADY KNOW THE threadId — it returns the thread in full, so unlike a search it can never be truncated, mis-ranked, or come back empty because the query missed. Never re-discover a known thread with gog_gmail_search; read it here. For long threads that overflow context, use latestN to fetch only the most recent messages and/or snippetsOnly for a lightweight per-message headers+snippet view; sanitizeContent strips raw payloads/HTML and is the biggest size reducer when you do need bodies. Note each message carries two distinct id concepts: the top-level `id` (the Gmail short hex message id — pass THIS as replyToMessageId to reply) and the `Message-Id` header (the RFC822 `<…@host>` value used in In-Reply-To/References) — don\'t confuse either with the `threadId`. To reply to the thread itself, pass the thread\'s id as replyToThreadId on gog_gmail_drafts_create.',
|
|
2480
2482
|
annotations: { readOnlyHint: true },
|
|
2481
2483
|
inputSchema: {
|
|
2482
2484
|
threadId: z.string().describe('Gmail thread ID'),
|
|
@@ -2624,7 +2626,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2624
2626
|
annotations: { readOnlyHint: true },
|
|
2625
2627
|
inputSchema: {
|
|
2626
2628
|
max: z.number().optional().describe('Max results (default: 20)'),
|
|
2627
|
-
|
|
2629
|
+
pageToken: pageTokenParam,
|
|
2630
|
+
page: pageAliasParam,
|
|
2628
2631
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
2629
2632
|
enrich: z.boolean().optional().describe(
|
|
2630
2633
|
'Add subject, from and internalDateIso to each draft. Costs ONE extra gog invocation (`gmail messages search in:drafts`) ' +
|
|
@@ -2634,10 +2637,11 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2634
2637
|
),
|
|
2635
2638
|
account: accountParam,
|
|
2636
2639
|
},
|
|
2637
|
-
}, async ({ max, page, all, enrich, account }) => {
|
|
2640
|
+
}, async ({ max, pageToken, page, all, enrich, account }) => {
|
|
2638
2641
|
const args = ['gmail', 'drafts', 'list'];
|
|
2639
2642
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
2640
|
-
|
|
2643
|
+
const token = resolvePageToken({ pageToken, page });
|
|
2644
|
+
if (token) args.push(`--page=${token}`);
|
|
2641
2645
|
if (all) args.push('--all');
|
|
2642
2646
|
const result = await runOrDiagnose(args, { account });
|
|
2643
2647
|
|
|
@@ -2668,7 +2672,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2668
2672
|
// list is on page N: an extra gog spawn that joins ZERO rows and still
|
|
2669
2673
|
// reported applied:true. The token is a drafts-list cursor, so it is only
|
|
2670
2674
|
// meaningful to the paged search.
|
|
2671
|
-
if (
|
|
2675
|
+
if (token) searchArgs.push(`--page=${token}`);
|
|
2672
2676
|
// Both PINNED for the same reason as gog_gmail_messages_search: the env
|
|
2673
2677
|
// vars behind them change the result shape (and the per-message cost).
|
|
2674
2678
|
searchArgs.push('--include-attachments=false', '--use-indexed-attachment-ids=false');
|
|
@@ -2905,8 +2909,22 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2905
2909
|
// the raw write result if the id can't be determined.
|
|
2906
2910
|
/** The write succeeded; only the `returnFull` re-read did not. Hand back the
|
|
2907
2911
|
* acknowledgement, and say plainly which half failed — a caller that cannot
|
|
2908
|
-
* tell those apart will either re-send or delete the wrong copy.
|
|
2909
|
-
|
|
2912
|
+
* tell those apart will either re-send or delete the wrong copy.
|
|
2913
|
+
*
|
|
2914
|
+
* A missing id and any OTHER read failure are different stories and get
|
|
2915
|
+
* different text. Gating on DRAFT_NOT_FOUND_PATTERN — the same test
|
|
2916
|
+
* forkAwareDraftFailure uses — keeps the fork explanation for the case that
|
|
2917
|
+
* actually looks like one; a permission error, a timeout or a transport fault
|
|
2918
|
+
* keeps its own message instead of being retold as a fork, which would both
|
|
2919
|
+
* mislead and discard the only text saying what really went wrong. */
|
|
2920
|
+
function withRefetchNote(written: CallToolResult, draftId: string, refetch: CallToolResult): CallToolResult {
|
|
2921
|
+
const detail = resultText(refetch)?.trim();
|
|
2922
|
+
const looksForked = detail !== undefined && DRAFT_NOT_FOUND_PATTERN.test(detail);
|
|
2923
|
+
const because = looksForked
|
|
2924
|
+
? 'the id did not resolve, which on this mailbox usually means the draft was forked by a mail client ' +
|
|
2925
|
+
'between the write and the read. Run gog_gmail_drafts_list to find the current id'
|
|
2926
|
+
: `the read failed for a different reason, reported verbatim here: ${detail ?? '(no detail supplied)'}. ` +
|
|
2927
|
+
'That is a failure of the READ ONLY';
|
|
2910
2928
|
return {
|
|
2911
2929
|
...written,
|
|
2912
2930
|
content: [
|
|
@@ -2915,10 +2933,8 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2915
2933
|
type: 'text' as const,
|
|
2916
2934
|
text:
|
|
2917
2935
|
`Note: the write to draft ${draftId} SUCCEEDED and is acknowledged above. The follow-up ` +
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
`read. Nothing was lost. Run gog_gmail_drafts_list to find the current id, or ` +
|
|
2921
|
-
`gog_gmail_drafts_get on ${draftId} to confirm.`,
|
|
2936
|
+
`read-back requested by returnFull could not be performed — ${because}. Nothing was lost; ` +
|
|
2937
|
+
`run gog_gmail_drafts_get on ${draftId} to confirm the saved content.`,
|
|
2922
2938
|
},
|
|
2923
2939
|
],
|
|
2924
2940
|
};
|
|
@@ -2969,7 +2985,7 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
2969
2985
|
// most destructive thing this tool can say to someone about to tidy up
|
|
2970
2986
|
// the sibling copy.
|
|
2971
2987
|
if (refetched.isError !== true) final = refetched;
|
|
2972
|
-
else final = withRefetchNote(result, draftId);
|
|
2988
|
+
else final = withRefetchNote(result, draftId, refetched);
|
|
2973
2989
|
}
|
|
2974
2990
|
}
|
|
2975
2991
|
if (!verification) return final;
|
|
@@ -3266,12 +3282,17 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3266
3282
|
});
|
|
3267
3283
|
|
|
3268
3284
|
server.registerTool('gog_gmail_messages_search', {
|
|
3269
|
-
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.'
|
|
3285
|
+
description: 'Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message. '
|
|
3286
|
+
+ 'Results are ALWAYS newest-first by Gmail\'s internalDate — the wrapper sorts them, so the first result is the most recent match. '
|
|
3287
|
+
+ 'IMPORTANT — a response carrying "truncated": true is an INCOMPLETE view of the matches: NEVER report that a message does not exist on the strength of one. Page through it (pass nextPageToken back as `pageToken`), set maxPages to walk several pages in one call, or narrow the query first. '
|
|
3288
|
+
+ 'If you already know the thread, read it with gog_gmail_thread_get instead of searching for it.',
|
|
3270
3289
|
annotations: { readOnlyHint: true },
|
|
3271
3290
|
inputSchema: {
|
|
3272
3291
|
query: z.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
|
|
3273
3292
|
max: z.number().optional().describe('Max results'),
|
|
3274
|
-
|
|
3293
|
+
pageToken: pageTokenParam,
|
|
3294
|
+
page: pageAliasParam,
|
|
3295
|
+
maxPages: z.number().int().positive().max(20).optional().describe('Walk up to this many pages in ONE call and merge the results, instead of returning a single page. Use it for existence questions (\"is there any mail matching X?\"), which a single page cannot answer. Stops early at the last page; if pages remain when the cap is hit the response is still marked truncated. Prefer this over all=true, which is unbounded.'),
|
|
3275
3296
|
all: z.boolean().optional().describe('Fetch all pages'),
|
|
3276
3297
|
includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
|
|
3277
3298
|
full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
|
|
@@ -3280,10 +3301,9 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3280
3301
|
useIndexedAttachmentIds: z.boolean().optional().describe('Report each attachment as a 0-based `attachmentIndex` within its message instead of an opaque `attachmentId` (stable across calls, unlike the id). Only has an effect alongside includeAttachments or includeBody.'),
|
|
3281
3302
|
account: accountParam,
|
|
3282
3303
|
},
|
|
3283
|
-
}, async ({ query, max, page, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
3304
|
+
}, async ({ query, max, pageToken, page, maxPages, all, includeBody, full, bodyFormat, includeAttachments, useIndexedAttachmentIds, account }) => {
|
|
3284
3305
|
const args = ['gmail', 'messages', 'search', query];
|
|
3285
3306
|
if (max !== undefined) args.push(`--max=${max}`);
|
|
3286
|
-
if (page) args.push(`--page=${page}`);
|
|
3287
3307
|
if (all) args.push('--all');
|
|
3288
3308
|
if (includeBody) args.push('--include-body');
|
|
3289
3309
|
if (full) args.push('--full');
|
|
@@ -3293,7 +3313,19 @@ export function registerExtraGmailTools(server: McpServer): void {
|
|
|
3293
3313
|
// nothing in the arg array to show for it. See gog_gmail_thread_get.
|
|
3294
3314
|
args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
|
|
3295
3315
|
args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
|
|
3296
|
-
|
|
3316
|
+
// See gog_gmail_search: the cursor rides per page so the walk can advance it.
|
|
3317
|
+
const runPage = (tok: string | undefined) =>
|
|
3318
|
+
runOrDiagnose(tok ? [...args, `--page=${tok}`] : args, { account });
|
|
3319
|
+
const token = resolvePageToken({ pageToken, page });
|
|
3320
|
+
const result = maxPages !== undefined
|
|
3321
|
+
? await fetchGmailPages(runPage, 'messages', maxPages, token)
|
|
3322
|
+
: await runPage(token);
|
|
3323
|
+
return finalizeGmailSearch(result, {
|
|
3324
|
+
itemsKey: 'messages',
|
|
3325
|
+
method: 'users.messages.list',
|
|
3326
|
+
query,
|
|
3327
|
+
account,
|
|
3328
|
+
});
|
|
3297
3329
|
});
|
|
3298
3330
|
|
|
3299
3331
|
server.registerTool('gog_gmail_labels_style', {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { registerExtraGmailTools } from '../../src/tools/gmail-extra.js';
|
|
3
|
+
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
5
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
6
|
+
|
|
7
|
+
vi.mock('../../../gogcli-mcp/src/lib.js', async (o) => ({ ...(await o<typeof lib>()), run: vi.fn(), runOrDiagnose: vi.fn(), diagnose: vi.fn() }));
|
|
8
|
+
|
|
9
|
+
let harness: TestHarness;
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
vi.clearAllMocks();
|
|
12
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
13
|
+
vi.mocked(lib.diagnose).mockResolvedValue(errorResult('diagnosed'));
|
|
14
|
+
harness = await createTestHarness(registerExtraGmailTools);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const b64 = (t: string) => Buffer.from(t, 'utf8').toString('base64url');
|
|
18
|
+
const draft = (id: string, body: string) => JSON.stringify({
|
|
19
|
+
draft: {
|
|
20
|
+
id,
|
|
21
|
+
message: { id: `m${id}`, threadId: `t${id}`, payload: { mimeType: 'text/plain', headers: [{ name: 'Subject', value: 'S' }], body: { data: b64(body) } } },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* #264: bodyLineCount counted RAW normalized lines while diffBodyLines compares
|
|
27
|
+
* Set members, so onlyInACount + sharedLineCount === bodyLineCount stopped
|
|
28
|
+
* holding the moment a body repeated a line. Dividers repeat constantly in real
|
|
29
|
+
* mail, so this is the ordinary case, not an edge one.
|
|
30
|
+
*
|
|
31
|
+
* Asserted through the TOOL, against the emitted payload, because
|
|
32
|
+
* describeDraftSide is module-private — and because the payload is what a
|
|
33
|
+
* caller actually does arithmetic on.
|
|
34
|
+
*/
|
|
35
|
+
const A = ['Intro paragraph.', '---', 'Middle paragraph.', '---', 'Closing paragraph.'].join('\n');
|
|
36
|
+
const B = ['Intro paragraph.', '---', 'A different middle.', '---', 'Closing paragraph.'].join('\n');
|
|
37
|
+
|
|
38
|
+
describe('gog_gmail_drafts_diff count arithmetic', () => {
|
|
39
|
+
it('onlyInACount + sharedLineCount === bodyLineCount when a body repeats a line', async () => {
|
|
40
|
+
vi.mocked(lib.run).mockImplementation(async (args: readonly string[]) =>
|
|
41
|
+
draft(String(args[3]), String(args[3]) === 'a1' ? A : B));
|
|
42
|
+
|
|
43
|
+
const res = await harness.callTool('gog_gmail_drafts_diff', { draftIdA: 'a1', draftIdB: 'b1' });
|
|
44
|
+
const raw = res.content.map((c: any) => c.text).join('\n');
|
|
45
|
+
const payload = JSON.parse(raw);
|
|
46
|
+
|
|
47
|
+
const sideA = payload.drafts.a;
|
|
48
|
+
const diff = payload.bodyDiff;
|
|
49
|
+
|
|
50
|
+
expect(sideA.bodyLineCount).toBeDefined();
|
|
51
|
+
expect(diff.onlyInACount).toBeDefined();
|
|
52
|
+
expect(diff.sharedLineCount).toBeDefined();
|
|
53
|
+
// The body has 5 raw lines but only 4 distinct ones — that gap is the bug.
|
|
54
|
+
expect(sideA.bodyLineCount).toBe(4);
|
|
55
|
+
expect(diff.onlyInACount + diff.sharedLineCount).toBe(sideA.bodyLineCount);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -53,6 +53,43 @@ describe('a failed returnFull re-read does not erase a successful write', () =>
|
|
|
53
53
|
expect(res.content.map((c: any) => c.text).join('\n')).toContain('payload');
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it('does NOT retell a non-404 read failure as a fork, and keeps its text', async () => {
|
|
57
|
+
// #264: any refetch failure used to get the fork explanation, discarding the
|
|
58
|
+
// only text saying what actually went wrong. A permission error is not a fork.
|
|
59
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
|
|
60
|
+
args[2] === 'update' ? rawTextResult(ack) : errorResult('Google API error (403 forbidden): insufficient permission'));
|
|
61
|
+
|
|
62
|
+
const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
|
|
63
|
+
const text = res.content.map((c: any) => c.text).join('\n');
|
|
64
|
+
|
|
65
|
+
expect(res.isError).not.toBe(true);
|
|
66
|
+
expect(text).toContain('SUCCEEDED');
|
|
67
|
+
expect(text).toContain('403 forbidden'); // the real cause survives
|
|
68
|
+
expect(text).not.toMatch(/forked by a mail client/); // and is not retold as a fork
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('still gives the fork explanation on a genuine 404', async () => {
|
|
72
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
|
|
73
|
+
args[2] === 'update' ? rawTextResult(ack) : errorResult('Google API error (404 notFound)'));
|
|
74
|
+
const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
|
|
75
|
+
expect(res.content.map((c: any) => c.text).join('\n')).toMatch(/forked by a mail client/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('still says the write succeeded when the read failure carries no text', async () => {
|
|
79
|
+
// An error result with no content at all: the note must not interpolate
|
|
80
|
+
// `undefined` into the sentence a caller reads to decide what to do next.
|
|
81
|
+
vi.mocked(lib.runOrDiagnose).mockImplementation(async (args: readonly string[]) =>
|
|
82
|
+
args[2] === 'update' ? rawTextResult(ack) : ({ isError: true, content: [] } as any));
|
|
83
|
+
|
|
84
|
+
const res = await harness.callTool('gog_gmail_drafts_update', { draftId: 'r123', subject: 'S', body: 'x', returnFull: true });
|
|
85
|
+
const text = res.content.map((c: any) => c.text).join('\n');
|
|
86
|
+
|
|
87
|
+
expect(res.isError).not.toBe(true);
|
|
88
|
+
expect(text).toContain('SUCCEEDED');
|
|
89
|
+
expect(text).toContain('(no detail supplied)');
|
|
90
|
+
expect(text).not.toContain('undefined');
|
|
91
|
+
});
|
|
92
|
+
|
|
56
93
|
it('still reports a genuinely failed WRITE as an error', async () => {
|
|
57
94
|
// The guard must not swallow a real failure: here the update itself fails.
|
|
58
95
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(errorResult('Google API error (404 notFound)'));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { registerExtraGmailTools } from '../../src/tools/gmail-extra.js';
|
|
3
3
|
import * as lib from '../../../gogcli-mcp/src/lib.js';
|
|
4
|
+
import * as runner from '../../../gogcli-mcp/src/runner.js';
|
|
4
5
|
import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
|
|
5
6
|
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
6
7
|
|
|
@@ -14,6 +15,16 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
|
|
|
14
15
|
};
|
|
15
16
|
});
|
|
16
17
|
|
|
18
|
+
// finalizeGmailSearch reaches for runner.run DIRECTLY (not the lib re-export
|
|
19
|
+
// the mock above replaces) to count matches behind a truncated result set.
|
|
20
|
+
// Without this the probe would spawn the real `gog` and hit the live Gmail API
|
|
21
|
+
// from a unit test. Only `run` is replaced — runExecutor is a real
|
|
22
|
+
// AsyncLocalStorage the connector-shape tests depend on.
|
|
23
|
+
vi.mock('../../../gogcli-mcp/src/runner.js', async (importOriginal) => {
|
|
24
|
+
const actual = await importOriginal<typeof runner>();
|
|
25
|
+
return { ...actual, run: vi.fn() };
|
|
26
|
+
});
|
|
27
|
+
|
|
17
28
|
let harness: TestHarness;
|
|
18
29
|
|
|
19
30
|
beforeEach(async () => {
|
|
@@ -21,6 +32,9 @@ beforeEach(async () => {
|
|
|
21
32
|
vi.mocked(lib.run).mockResolvedValue('{}');
|
|
22
33
|
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
|
|
23
34
|
vi.mocked(lib.diagnose).mockResolvedValue(errorResult('diagnosed'));
|
|
35
|
+
// Default: the match-count probe finds nothing to report, so no test depends
|
|
36
|
+
// on a live call. Tests that care stub it explicitly.
|
|
37
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('no count probe stubbed'));
|
|
24
38
|
harness = await createTestHarness(registerExtraGmailTools);
|
|
25
39
|
});
|
|
26
40
|
|
|
@@ -1735,7 +1749,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1735
1749
|
await harness.callTool('gog_gmail_messages_search', {
|
|
1736
1750
|
query: 'is:unread',
|
|
1737
1751
|
max: 10,
|
|
1738
|
-
|
|
1752
|
+
pageToken: 'tok',
|
|
1739
1753
|
all: true,
|
|
1740
1754
|
includeBody: true,
|
|
1741
1755
|
full: true,
|
|
@@ -1743,7 +1757,7 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1743
1757
|
account: 'me@x.com',
|
|
1744
1758
|
});
|
|
1745
1759
|
expect(lib.runOrDiagnose).toHaveBeenCalledWith(
|
|
1746
|
-
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--
|
|
1760
|
+
['gmail', 'messages', 'search', 'is:unread', '--max=10', '--all', '--include-body', '--full', '--body-format=html', '--include-attachments=false', '--use-indexed-attachment-ids=false', '--page=tok'],
|
|
1747
1761
|
{ account: 'me@x.com' },
|
|
1748
1762
|
);
|
|
1749
1763
|
});
|
|
@@ -1757,6 +1771,98 @@ describe('gog_gmail_messages_search', () => {
|
|
|
1757
1771
|
});
|
|
1758
1772
|
});
|
|
1759
1773
|
|
|
1774
|
+
describe('page-cursor contract', () => {
|
|
1775
|
+
it('never steers a caller to the deprecated `page` alias', async () => {
|
|
1776
|
+
const { tools } = await harness.client.listTools();
|
|
1777
|
+
const offenders = (tools as { name: string; description?: string }[])
|
|
1778
|
+
.filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
|
|
1779
|
+
.map((t) => t.name);
|
|
1780
|
+
expect(offenders).toEqual([]);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('offers the alias wherever pageToken is accepted', async () => {
|
|
1784
|
+
const { tools } = await harness.client.listTools();
|
|
1785
|
+
const withCursor = (tools as { name: string; inputSchema?: { properties?: Record<string, unknown> } }[])
|
|
1786
|
+
.filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
|
|
1787
|
+
expect(withCursor.length).toBeGreaterThan(0);
|
|
1788
|
+
expect(withCursor.filter((t) => !('page' in (t.inputSchema?.properties ?? {}))).map((t) => t.name)).toEqual([]);
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
describe('gog_gmail_messages_search — the page cursor reaches the API', () => {
|
|
1793
|
+
it('threads a pageToken through to the gog invocation', async () => {
|
|
1794
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'CURSOR' });
|
|
1795
|
+
const args = vi.mocked(lib.runOrDiagnose).mock.calls[0][0] as string[];
|
|
1796
|
+
expect(args).toContain('--page=CURSOR');
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
it('still accepts the deprecated page alias, and pageToken wins over it', async () => {
|
|
1800
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', page: 'OLD' });
|
|
1801
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=OLD');
|
|
1802
|
+
vi.mocked(lib.runOrDiagnose).mockClear();
|
|
1803
|
+
await harness.callTool('gog_gmail_messages_search', { query: 'x', pageToken: 'NEW', page: 'OLD' });
|
|
1804
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[0][0]).toContain('--page=NEW');
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('walks and merges pages under maxPages', async () => {
|
|
1808
|
+
vi.mocked(lib.runOrDiagnose)
|
|
1809
|
+
.mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'T1' })))
|
|
1810
|
+
.mockResolvedValueOnce(rawTextResult(JSON.stringify({ messages: [{ id: 'b' }] })));
|
|
1811
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', maxPages: 4 });
|
|
1812
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1813
|
+
expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['a', 'b']);
|
|
1814
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
1815
|
+
expect(vi.mocked(lib.runOrDiagnose).mock.calls[1][0]).toContain('--page=T1');
|
|
1816
|
+
});
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
describe('gog_gmail_messages_search — result finalization', () => {
|
|
1820
|
+
it('sorts results newest-first', async () => {
|
|
1821
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
|
|
1822
|
+
messages: [
|
|
1823
|
+
{ id: 'old', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
1824
|
+
{ id: 'new', internalDateIso: '2026-08-12T12:36:00-04:00' },
|
|
1825
|
+
{ id: 'mid', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
1826
|
+
],
|
|
1827
|
+
nextPageToken: '',
|
|
1828
|
+
})));
|
|
1829
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
|
|
1830
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1831
|
+
expect(out.messages.map((m: { id: string }) => m.id)).toEqual(['new', 'mid', 'old']);
|
|
1832
|
+
expect(out).not.toHaveProperty('truncated');
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
it('marks a capped result set truncated and counts the real total', async () => {
|
|
1836
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult(JSON.stringify({
|
|
1837
|
+
messages: [{ id: 'a' }, { id: 'b' }],
|
|
1838
|
+
nextPageToken: 'tok',
|
|
1839
|
+
})));
|
|
1840
|
+
vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
|
|
1841
|
+
messages: [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }],
|
|
1842
|
+
}));
|
|
1843
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x', max: 2 });
|
|
1844
|
+
const out = JSON.parse(result.content[0].text as string);
|
|
1845
|
+
expect(out.truncated).toBe(true);
|
|
1846
|
+
expect(out.returned).toBe(2);
|
|
1847
|
+
expect(out.totalMatches).toBe(5);
|
|
1848
|
+
expect(out.warning).toBe(
|
|
1849
|
+
'INCOMPLETE RESULT SET: returned 2 of 5 matches. Do not report an absence of results ' +
|
|
1850
|
+
'based on this response. Page with nextPageToken or narrow the query.',
|
|
1851
|
+
);
|
|
1852
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
1853
|
+
['api', 'call', 'gmail', 'v1', 'users.messages.list',
|
|
1854
|
+
'--params={"userId":"me","q":"x","maxResults":500,"fields":"messages/id,nextPageToken"}'],
|
|
1855
|
+
{ account: undefined },
|
|
1856
|
+
);
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
it('leaves output it does not recognise untouched', async () => {
|
|
1860
|
+
vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('No results'));
|
|
1861
|
+
const result = await harness.callTool('gog_gmail_messages_search', { query: 'x' });
|
|
1862
|
+
expect(result.content[0].text).toBe('No results');
|
|
1863
|
+
});
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1760
1866
|
describe('gog_gmail_labels_style', () => {
|
|
1761
1867
|
it('calls runOrDiagnose with just the label', async () => {
|
|
1762
1868
|
await harness.callTool('gog_gmail_labels_style', { labelIdOrName: 'Work' });
|