gogcli-mcp-gmail 2.23.1 → 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
- page: external_exports.string().optional().describe("Page token"),
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: `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).`,
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
- return runOrDiagnose(args, { account });
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.1" : "0.0.0";
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
- page: external_exports.string().optional().describe("Page token"),
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
- if (page) args.push(`--page=${page}`);
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
- page: external_exports.string().optional().describe("Page token"),
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
- if (page) args.push(`--page=${page}`);
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 (page) searchArgs.push(`--page=${page}`);
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;
@@ -34121,12 +34277,14 @@ function registerExtraGmailTools(server) {
34121
34277
  return runOrDiagnose(args, { account });
34122
34278
  });
34123
34279
  server.registerTool("gog_gmail_messages_search", {
34124
- description: "Search individual messages (not threads) using Gmail query syntax. Returns one result per matching message.",
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.',
34125
34281
  annotations: { readOnlyHint: true },
34126
34282
  inputSchema: {
34127
34283
  query: external_exports.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
34128
34284
  max: external_exports.number().optional().describe("Max results"),
34129
- page: external_exports.string().optional().describe("Page token"),
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.'),
34130
34288
  all: external_exports.boolean().optional().describe("Fetch all pages"),
34131
34289
  includeBody: external_exports.boolean().optional().describe("Include the decoded message body in each result"),
34132
34290
  full: external_exports.boolean().optional().describe("Show full message bodies without truncation (implies includeBody)"),
@@ -34135,17 +34293,24 @@ function registerExtraGmailTools(server) {
34135
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."),
34136
34294
  account: accountParam
34137
34295
  }
34138
- }, 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 }) => {
34139
34297
  const args = ["gmail", "messages", "search", query];
34140
34298
  if (max !== void 0) args.push(`--max=${max}`);
34141
- if (page) args.push(`--page=${page}`);
34142
34299
  if (all) args.push("--all");
34143
34300
  if (includeBody) args.push("--include-body");
34144
34301
  if (full) args.push("--full");
34145
34302
  if (bodyFormat) args.push(`--body-format=${bodyFormat}`);
34146
34303
  args.push(includeAttachments ? "--include-attachments" : "--include-attachments=false");
34147
34304
  args.push(useIndexedAttachmentIds ? "--use-indexed-attachment-ids" : "--use-indexed-attachment-ids=false");
34148
- return runOrDiagnose(args, { account });
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
+ });
34149
34314
  });
34150
34315
  server.registerTool("gog_gmail_labels_style", {
34151
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.1",
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 (not threads) using Gmail query syntax"
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.1",
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>",
@@ -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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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
- page: z.string().optional().describe('Page token'),
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
- if (page) args.push(`--page=${page}`);
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 (page) searchArgs.push(`--page=${page}`);
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');
@@ -3278,12 +3282,17 @@ export function registerExtraGmailTools(server: McpServer): void {
3278
3282
  });
3279
3283
 
3280
3284
  server.registerTool('gog_gmail_messages_search', {
3281
- 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.',
3282
3289
  annotations: { readOnlyHint: true },
3283
3290
  inputSchema: {
3284
3291
  query: z.string().describe('Gmail search query (e.g. "from:alice is:unread has:attachment")'),
3285
3292
  max: z.number().optional().describe('Max results'),
3286
- page: z.string().optional().describe('Page token'),
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.'),
3287
3296
  all: z.boolean().optional().describe('Fetch all pages'),
3288
3297
  includeBody: z.boolean().optional().describe('Include the decoded message body in each result'),
3289
3298
  full: z.boolean().optional().describe('Show full message bodies without truncation (implies includeBody)'),
@@ -3292,10 +3301,9 @@ export function registerExtraGmailTools(server: McpServer): void {
3292
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.'),
3293
3302
  account: accountParam,
3294
3303
  },
3295
- }, 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 }) => {
3296
3305
  const args = ['gmail', 'messages', 'search', query];
3297
3306
  if (max !== undefined) args.push(`--max=${max}`);
3298
- if (page) args.push(`--page=${page}`);
3299
3307
  if (all) args.push('--all');
3300
3308
  if (includeBody) args.push('--include-body');
3301
3309
  if (full) args.push('--full');
@@ -3305,7 +3313,19 @@ export function registerExtraGmailTools(server: McpServer): void {
3305
3313
  // nothing in the arg array to show for it. See gog_gmail_thread_get.
3306
3314
  args.push(includeAttachments ? '--include-attachments' : '--include-attachments=false');
3307
3315
  args.push(useIndexedAttachmentIds ? '--use-indexed-attachment-ids' : '--use-indexed-attachment-ids=false');
3308
- return runOrDiagnose(args, { account });
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
+ });
3309
3329
  });
3310
3330
 
3311
3331
  server.registerTool('gog_gmail_labels_style', {
@@ -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
- page: 'tok',
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', '--page=tok', '--all', '--include-body', '--full', '--body-format=html', '--include-attachments=false', '--use-indexed-attachment-ids=false'],
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' });