gogcli-mcp 2.23.1 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp",
5
5
  "display_name": "gogcli",
6
- "version": "2.23.1",
6
+ "version": "2.24.0",
7
7
  "description": "Google Sheets (and more) for Claude via gogcli — read, write, and manage spreadsheets",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -102,7 +102,7 @@
102
102
  },
103
103
  {
104
104
  "name": "gog_gmail_search",
105
- "description": "Search Gmail threads by query"
105
+ "description": "Search Gmail threads using Gmail query syntax; newest-first, and flags a truncated result set"
106
106
  },
107
107
  {
108
108
  "name": "gog_gmail_get",
@@ -118,7 +118,7 @@
118
118
  },
119
119
  {
120
120
  "name": "gog_calendar_events",
121
- "description": "List calendar events with optional filters"
121
+ "description": "List calendar events; paginated (gog returns only 10 by default) and flags a truncated range"
122
122
  },
123
123
  {
124
124
  "name": "gog_calendar_get",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp",
3
- "version": "2.23.1",
3
+ "version": "2.24.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp",
5
5
  "description": "MCP server wrapping gogcli for Google service access",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "source": "github",
8
8
  "subfolder": "packages/gogcli-mcp"
9
9
  },
10
- "version": "2.23.1",
10
+ "version": "2.24.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "identifier": "gogcli-mcp",
15
- "version": "2.23.1",
15
+ "version": "2.24.0",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  },
@@ -0,0 +1,239 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import { rawTextResult } from '@chrischall/mcp-utils';
3
+ import { run } from './runner.js';
4
+ import { annotateTruncation, hasMorePages } from './pagination.js';
5
+ import type { MatchCount } from './pagination.js';
6
+
7
+ // Post-processing for Gmail search output, on the seam between gog's JSON and
8
+ // the model client. Two guarantees live here, both of which exist because a
9
+ // client read a search response and confidently reported that a message did not
10
+ // exist when it did:
11
+ //
12
+ // 1. A capped result set SAYS SO, in prose. gog reports a nextPageToken and
13
+ // nothing else; a token is easy to skim past, and skipping it turns a
14
+ // partial answer into a false negative.
15
+ // 2. Results are newest-first by internalDate. Gmail's own thread ordering
16
+ // keys on the thread, so a message that arrived today can rank below
17
+ // older ones and fall off the end of a capped page.
18
+
19
+ type GmailListItem = Record<string, unknown> & {
20
+ date?: string;
21
+ internalDateIso?: string;
22
+ };
23
+
24
+ // Sort key for newest-first. internalDateIso is Gmail's own internalDate and is
25
+ // what a parser should read; `date` parses the sender-written Date header and
26
+ // can be skewed, malformed, or in another zone, so it is only a fallback.
27
+ // Anything unparseable sorts LAST rather than being silently treated as the
28
+ // epoch — which would rank it as the oldest result, the exact failure this
29
+ // guarantee exists to remove.
30
+ function sortKey(item: GmailListItem): number {
31
+ for (const raw of [item.internalDateIso, item.date]) {
32
+ if (typeof raw !== 'string' || !raw) continue;
33
+ const t = Date.parse(raw);
34
+ if (!Number.isNaN(t)) return t;
35
+ }
36
+ return Number.NEGATIVE_INFINITY;
37
+ }
38
+
39
+ // Array.prototype.sort is stable per spec, so equal keys — and the whole
40
+ // undated tail, which shares NEGATIVE_INFINITY — keep their original order.
41
+ function sortNewestFirst(items: GmailListItem[]): GmailListItem[] {
42
+ return [...items].sort((a, b) => {
43
+ const ka = sortKey(a);
44
+ const kb = sortKey(b);
45
+ // Compare before subtracting: (-Infinity) - (-Infinity) is NaN, which would
46
+ // make the comparator incoherent for every undated pair.
47
+ return ka === kb ? 0 : kb - ka;
48
+ });
49
+ }
50
+
51
+ export type GmailListMethod = 'users.threads.list' | 'users.messages.list';
52
+
53
+ // The largest page Gmail's list endpoints will serve. One page at this size is
54
+ // enough to count the whole match set for all but the broadest queries.
55
+ const COUNT_PROBE_PAGE_SIZE = 500;
56
+
57
+ // Count how many matches the query REALLY has.
58
+ //
59
+ // Deliberately NOT Gmail's own resultSizeEstimate, which is the obvious source
60
+ // and is worthless: measured against a live mailbox on 2026-08-12 it returned
61
+ // exactly 201 for every non-empty query tried — `from:freshbooks.com` (21 real
62
+ // matches), `from:housecallpro.com` (6), `from:thumbtack.com newer_than:30d`
63
+ // (3) — and 0 for a query with no matches. It saturates, so it is a
64
+ // has-results boolean wearing a number's clothes. Reporting "3 of ~201" when
65
+ // the truth is 3 of 6 would invent a figure, which is a worse failure than the
66
+ // missing count this whole block exists to supply.
67
+ //
68
+ // So: ask for one maximal page of bare ids and count them. `fields` keeps the
69
+ // response to ids alone, and the count is EXACT whenever the result set fits in
70
+ // a page — which is the common case, and always the case for the narrow queries
71
+ // a caller is most likely to draw a false negative from. A full page with more
72
+ // behind it yields an honest lower bound instead.
73
+ //
74
+ // gog CAN now supply this itself — `--count` on `gmail search` / `gmail
75
+ // messages search` (gog >= 0.36.0, openclaw/gogcli#985) is this same probe,
76
+ // upstreamed, down to the page size and the exact/lower-bound split. The probe
77
+ // stays here anyway, for two differences that both matter at this seam:
78
+ //
79
+ // * It is spent ONLY on a result set already known to be truncated. --count
80
+ // is decided before the search runs, so adopting it would cost every
81
+ // search an extra Gmail request to answer a question most of them never
82
+ // raise.
83
+ // * It is best-effort. gog returns the count probe's error from the whole
84
+ // command, so a failed count would turn a search that DID succeed into an
85
+ // error — trading a missing warning for a missing answer.
86
+ //
87
+ // Keep the two in step: a change to what "exact" means on either side should
88
+ // be made on both.
89
+ //
90
+ // Best-effort by construction: any failure must degrade the warning, never the
91
+ // search.
92
+ async function countMatches(
93
+ method: GmailListMethod,
94
+ itemsKey: 'threads' | 'messages',
95
+ query: string,
96
+ account: string | undefined,
97
+ ): Promise<MatchCount> {
98
+ try {
99
+ const params = JSON.stringify({
100
+ userId: 'me',
101
+ q: query,
102
+ maxResults: COUNT_PROBE_PAGE_SIZE,
103
+ fields: `${itemsKey}/id,nextPageToken`,
104
+ });
105
+ const raw = await run(['api', 'call', 'gmail', 'v1', method, `--params=${params}`], { account });
106
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
107
+ const items = parsed[itemsKey];
108
+ if (!Array.isArray(items)) return {};
109
+ const more = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== '';
110
+ return more ? { atLeast: items.length } : { total: items.length };
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+
116
+ export interface FinalizeOptions {
117
+ /** Which array in gog's payload holds the results. */
118
+ itemsKey: 'threads' | 'messages';
119
+ /** The Discovery method whose resultSizeEstimate matches that array. */
120
+ method: GmailListMethod;
121
+ /** The query as the caller wrote it. */
122
+ query: string;
123
+ account?: string;
124
+ /**
125
+ * False when gog rewrote the query server-side (--from-contact resolves a
126
+ * contact through the People API to addresses we cannot reproduce). An
127
+ * estimate for a DIFFERENT query is worse than none, so the field is skipped
128
+ * rather than guessed.
129
+ */
130
+ queryIsExact?: boolean;
131
+ }
132
+
133
+ // The single seam every Gmail search result passes through: guarantee the
134
+ // ordering, and make a capped result set say so out loud. Any output that is
135
+ // not the JSON shape this understands — an error, a plain "No results" — is
136
+ // returned untouched.
137
+ export async function finalizeGmailSearch(
138
+ result: CallToolResult,
139
+ options: FinalizeOptions,
140
+ ): Promise<CallToolResult> {
141
+ const { itemsKey, method, query, account, queryIsExact = true } = options;
142
+ const first = result.content[0];
143
+ if (result.isError || first?.type !== 'text') return result;
144
+
145
+ let parsed: Record<string, unknown>;
146
+ try {
147
+ parsed = JSON.parse(first.text) as Record<string, unknown>;
148
+ } catch {
149
+ return result;
150
+ }
151
+ const items = parsed[itemsKey];
152
+ if (!Array.isArray(items)) return result;
153
+
154
+ const sorted = sortNewestFirst(items as GmailListItem[]);
155
+ const out: Record<string, unknown> = { ...parsed, [itemsKey]: sorted };
156
+
157
+ // The presence of the block is itself the signal, so it is added ONLY for a
158
+ // genuinely capped set. gog leaves nextPageToken empty when --all exhausted
159
+ // the pages, which is why --all needs no special case here.
160
+ if (hasMorePages(parsed)) {
161
+ const count = queryIsExact ? await countMatches(method, itemsKey, query, account) : {};
162
+ annotateTruncation(out, sorted.length, count);
163
+ }
164
+
165
+ return rawTextResult(JSON.stringify(out));
166
+ }
167
+
168
+ // Walk up to `maxPages` pages and merge them into one payload.
169
+ //
170
+ // The point is the existence question — "is there any mail matching X?" — which
171
+ // a single page cannot answer and which is exactly where a truncated response
172
+ // gets misread as a negative. gog has `--all`, but that is unbounded: on a
173
+ // broad query it walks the entire mailbox. This is the bounded form, so a
174
+ // caller can say "look at up to 5 pages" and get either a real answer or an
175
+ // honestly-still-truncated one.
176
+ //
177
+ // The merged payload keeps a nextPageToken only if pages remain when the cap is
178
+ // hit, so finalizeGmailSearch still marks it truncated — running out of budget
179
+ // is not the same as reaching the end, and must not read like it.
180
+ export async function fetchGmailPages(
181
+ runPage: (token: string | undefined) => Promise<CallToolResult>,
182
+ itemsKey: 'threads' | 'messages',
183
+ maxPages: number,
184
+ startToken: string | undefined,
185
+ ): Promise<CallToolResult> {
186
+ const merged: unknown[] = [];
187
+ let base: Record<string, unknown> | undefined;
188
+ let token = startToken;
189
+
190
+ for (let pages = 0; pages < maxPages; pages++) {
191
+ const result = await runPage(token);
192
+ const parsed = parsePage(result, itemsKey);
193
+ // An error, or output this does not understand, part-way through the walk.
194
+ // Nothing collected yet means the caller should just see that result; once
195
+ // pages ARE collected, return them WITH the cursor that was about to be
196
+ // consumed, so the set still reads as truncated rather than complete.
197
+ if (parsed === undefined) {
198
+ return base === undefined ? result : finish(base, itemsKey, merged, token);
199
+ }
200
+ base = parsed;
201
+ merged.push(...(parsed[itemsKey] as unknown[]));
202
+ token = typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== ''
203
+ ? parsed.nextPageToken
204
+ : undefined;
205
+ if (token === undefined) break;
206
+ }
207
+
208
+ return finish(base as Record<string, unknown>, itemsKey, merged, token);
209
+ }
210
+
211
+ // A page's payload, or undefined if it is not a usable list response.
212
+ function parsePage(
213
+ result: CallToolResult,
214
+ itemsKey: string,
215
+ ): Record<string, unknown> | undefined {
216
+ const first = result.content[0];
217
+ if (result.isError || first?.type !== 'text') return undefined;
218
+ let parsed: unknown;
219
+ try {
220
+ parsed = JSON.parse(first.text);
221
+ } catch {
222
+ return undefined;
223
+ }
224
+ if (parsed === null || typeof parsed !== 'object') return undefined;
225
+ const obj = parsed as Record<string, unknown>;
226
+ return Array.isArray(obj[itemsKey]) ? obj : undefined;
227
+ }
228
+
229
+ function finish(
230
+ base: Record<string, unknown>,
231
+ itemsKey: string,
232
+ merged: unknown[],
233
+ token: string | undefined,
234
+ ): CallToolResult {
235
+ const out: Record<string, unknown> = { ...base, [itemsKey]: merged };
236
+ if (token === undefined) delete out.nextPageToken;
237
+ else out.nextPageToken = token;
238
+ return rawTextResult(JSON.stringify(out));
239
+ }
package/src/lib.ts CHANGED
@@ -19,6 +19,12 @@ export { run, runBinary, runExecutor, isGogFileArg, MIN_GOG_VERSION } from './ru
19
19
  // `runOrDiagnose` seam) must still apply this, or their timestamps skip the
20
20
  // offset repair and the `<field>Display` sibling every other tool returns.
21
21
  export { normalizeTimestamps } from './timestamps.js';
22
+ export { annotateTruncatedList, stripConsumedPageToken } from './pagination.js';
23
+ // Search-result finalization (newest-first ordering + loud truncation metadata).
24
+ // Sub-package search tools must route their output through this or they lose both
25
+ // guarantees the base gog_gmail_search makes.
26
+ export { finalizeGmailSearch, fetchGmailPages } from './gmail-results.js';
27
+ export type { FinalizeOptions, GmailListMethod } from './gmail-results.js';
22
28
  export { useRemoteGogRunner } from './remote-runner.js';
23
29
  export type { RunOptions, Spawner, GogExecutor, GogArg, GogFileArg } from './runner.js';
24
30
  export {
@@ -31,5 +37,8 @@ export {
31
37
  ids,
32
38
  paginationParams,
33
39
  pushPaginationFlags,
40
+ pageTokenParam,
41
+ pageAliasParam,
42
+ resolvePageToken,
34
43
  registerRunTool,
35
44
  } from './tools/utils.js';
@@ -0,0 +1,108 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import { rawTextResult } from '@chrischall/mcp-utils';
3
+
4
+ // gog reports an exhausted cursor as `"nextPageToken": ""` rather than omitting
5
+ // the field. That empty string is worse than nothing: the natural reading of a
6
+ // paginated response is "the cursor key is there, so there is another page",
7
+ // and a caller acting on the key's PRESENCE pages forever or, worse, concludes
8
+ // the opposite of the truth. Strip it so the field means exactly one thing —
9
+ // present iff another page exists.
10
+ //
11
+ // Deliberately top-level only. A nested `nextPageToken` belongs to some
12
+ // embedded resource whose paging semantics are not ours to reinterpret.
13
+
14
+ // Byte-identical passthrough is the contract when nothing was removed — see
15
+ // normalizeTimestamps, which this sits beside on the same seam and whose
16
+ // indent-preserving discipline it copies.
17
+ function detectIndent(text: string): number {
18
+ const match = /\n(\s+)\S/.exec(text);
19
+ return match ? match[1].replace(/\t/g, ' ').length : 0;
20
+ }
21
+
22
+ export function stripConsumedPageToken(text: string): string {
23
+ const trimmed = text.trim();
24
+ if (trimmed === '' || !trimmed.startsWith('{')) return text;
25
+ let parsed: unknown;
26
+ try {
27
+ parsed = JSON.parse(trimmed);
28
+ } catch {
29
+ return text;
30
+ }
31
+ // The `{` guard above means anything that parses here is a plain object —
32
+ // never null, an array, or a scalar — so no further shape check is needed.
33
+ const obj = parsed as Record<string, unknown>;
34
+ if (obj.nextPageToken !== '') return text;
35
+ delete obj.nextPageToken;
36
+ return JSON.stringify(obj, null, detectIndent(text));
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Truncation metadata
41
+ // ---------------------------------------------------------------------------
42
+
43
+ // How many items the query really has, when we were able to find out.
44
+ export interface MatchCount {
45
+ /** Exact total — the probe reached the end of the result set. */
46
+ total?: number;
47
+ /** Lower bound — the probe filled its page and more remain. */
48
+ atLeast?: number;
49
+ }
50
+
51
+ // The prose matters more than the boolean. A bare `truncated: true` is exactly
52
+ // the kind of structured field a model client skims past — which is how a
53
+ // non-empty nextPageToken produced a confident "no such email exists". This
54
+ // says, in words, the one conclusion the caller must not draw.
55
+ export function truncationWarning(returned: number, count: MatchCount): string {
56
+ const scope = count.total !== undefined
57
+ ? `returned ${returned} of ${count.total} matches`
58
+ : count.atLeast !== undefined
59
+ ? `returned ${returned} of at least ${count.atLeast} matches`
60
+ : `returned ${returned} matches and MORE EXIST beyond this page`;
61
+ return `INCOMPLETE RESULT SET: ${scope}. Do not report an absence of results based on ` +
62
+ 'this response. Page with nextPageToken or narrow the query.';
63
+ }
64
+
65
+ // Add the truncation block to a parsed payload, in place.
66
+ //
67
+ // Only ever called for a genuinely capped set, so the block's PRESENCE is
68
+ // itself the signal and a complete response stays clean. By the time this runs
69
+ // an exhausted cursor has already been stripped by stripConsumedPageToken, so
70
+ // "has a nextPageToken" and "has another page" are the same question.
71
+ export function annotateTruncation(
72
+ out: Record<string, unknown>,
73
+ returned: number,
74
+ count: MatchCount,
75
+ ): void {
76
+ out.truncated = true;
77
+ out.returned = returned;
78
+ if (count.total !== undefined) out.totalMatches = count.total;
79
+ if (count.atLeast !== undefined) out.totalMatchesAtLeast = count.atLeast;
80
+ out.warning = truncationWarning(returned, count);
81
+ }
82
+
83
+ // True when the payload still carries a live cursor.
84
+ export function hasMorePages(parsed: Record<string, unknown>): boolean {
85
+ return typeof parsed.nextPageToken === 'string' && parsed.nextPageToken !== '';
86
+ }
87
+
88
+ // Attach the truncation block to a tool result whose payload we cannot cheaply
89
+ // count — the fact of truncation without a fabricated total. Anything that is
90
+ // not the JSON shape this understands passes through untouched.
91
+ export function annotateTruncatedList(
92
+ result: CallToolResult,
93
+ itemsKey: string,
94
+ ): CallToolResult {
95
+ const first = result.content[0];
96
+ if (result.isError || first?.type !== 'text') return result;
97
+ let parsed: Record<string, unknown>;
98
+ try {
99
+ parsed = JSON.parse(first.text) as Record<string, unknown>;
100
+ } catch {
101
+ return result;
102
+ }
103
+ const items = parsed[itemsKey];
104
+ if (!Array.isArray(items) || !hasMorePages(parsed)) return result;
105
+ const out: Record<string, unknown> = { ...parsed };
106
+ annotateTruncation(out, items.length, {});
107
+ return rawTextResult(JSON.stringify(out));
108
+ }
package/src/runner.ts CHANGED
@@ -167,7 +167,7 @@ const TIMEOUT_MS = 30_000;
167
167
  // so the requirement change is surfaced in the release notes (see
168
168
  // .github/release.yml). This is the single source of truth for the required
169
169
  // version; keep the README/CLAUDE.md mention in sync.
170
- export const MIN_GOG_VERSION = '0.35.0';
170
+ export const MIN_GOG_VERSION = '0.37.0';
171
171
 
172
172
  // Interpret the GOG_READONLY kill-switch. `readEnvVar` already treats blank
173
173
  // values, 'undefined'/'null' sentinels, and unresolved .mcpb placeholders
package/src/tools/auth.ts CHANGED
@@ -13,6 +13,20 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
13
13
  `Default: "${defaultServices}". Prefer the narrowest set you need — requesting a service whose ` +
14
14
  `Google API is not enabled on the OAuth client's project makes Google reject the WHOLE request ` +
15
15
  `with invalid_scope.`;
16
+ // Additional raw OAuth scope URIs, appended after the service scopes gog
17
+ // derives from `services`. This exists for scopes no service selection can
18
+ // ask for: notably bigquery.readonly, which Google demands whenever a Sheets
19
+ // response CONTAINS BigQuery Connected Sheets data (gog_sheets_datasource_*)
20
+ // and which ordinary `sheets` authorization deliberately does not request.
21
+ // Same invalid_scope caveat as `services`: Google rejects the WHOLE request
22
+ // if the scope's API is not enabled on the OAuth client's project, and that
23
+ // happens in the user's browser, so this wrapper cannot catch it.
24
+ const extraScopesDescribe =
25
+ 'Additional raw OAuth scope URIs to request, comma-separated, on top of the ones `services` implies. ' +
26
+ 'Use for scopes no service covers — e.g. https://www.googleapis.com/auth/bigquery.readonly, required ' +
27
+ 'before gog_sheets_datasource_* can read BigQuery-backed Connected Sheets. Leave unset otherwise: an ' +
28
+ 'extra scope whose API is not enabled on the OAuth client project makes Google reject the WHOLE ' +
29
+ 'authorization with invalid_scope.';
16
30
  server.registerTool('gog_auth_list', {
17
31
  description:
18
32
  'List the Google accounts stored in gogcli, with their scopes. This reads local ' +
@@ -91,10 +105,19 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
91
105
  inputSchema: {
92
106
  email: z.string().describe('Google account email to authorize'),
93
107
  services: z.string().optional().default(defaultServices).describe(servicesDescribe),
108
+ extraScopes: z.string().optional().describe(extraScopesDescribe),
94
109
  },
95
- }, async ({ email, services = defaultServices }) => {
110
+ }, async ({ email, services = defaultServices, extraScopes }) => {
96
111
  try {
97
- return rawTextResult(await run(['auth', 'add', email, '--services', services], {
112
+ const args = ['auth', 'add', email, '--services', services];
113
+ // --force-consent rides along with extraScopes and only with them. Google
114
+ // re-prompts for a NEW scope only when consent is forced; without it the
115
+ // account can come back still missing the scope, with a success message —
116
+ // the exact shape of failure the caller cannot see. The other two tools
117
+ // force consent unconditionally; this one does not, so it must be added
118
+ // here rather than assumed.
119
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`, '--force-consent');
120
+ return rawTextResult(await run(args, {
98
121
  interactive: true,
99
122
  timeout: 300_000,
100
123
  }));
@@ -115,17 +138,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
115
138
  inputSchema: {
116
139
  email: z.string().describe('Google account email to authorize'),
117
140
  services: z.string().optional().default(defaultServices).describe(servicesDescribe),
141
+ extraScopes: z.string().optional().describe(`${extraScopesDescribe} Pass the SAME value to gog_auth_add_complete.`),
118
142
  },
119
- }, async ({ email, services = defaultServices }) => {
143
+ }, async ({ email, services = defaultServices, extraScopes }) => {
120
144
  try {
121
145
  // --force-consent guarantees a refresh token even if a prior grant exists
122
146
  // (the whole point when recovering from a dead one). redactMode 'tokens'
123
147
  // keeps the consent URL's scope names intact (the shared redactor mangles
124
148
  // them) while still stripping any real token — a step-1 URL carries none.
125
- return rawTextResult(await run(
126
- ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'],
127
- { redactMode: 'tokens' },
128
- ));
149
+ const args = ['auth', 'add', email, '--remote', '--step', '1', '--services', services, '--force-consent'];
150
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
151
+ return rawTextResult(await run(args, { redactMode: 'tokens' }));
129
152
  } catch (err) {
130
153
  return errorResult(errorText(err));
131
154
  }
@@ -147,13 +170,17 @@ function registerAuthToolsWith(server: McpServer, defaultServices: string): void
147
170
  services: z.string().optional().default(defaultServices).describe(
148
171
  `Services authorized — MUST match the value passed to gog_auth_add_url. Default: "${defaultServices}".`,
149
172
  ),
173
+ extraScopes: z.string().optional().describe(
174
+ 'Extra OAuth scope URIs — MUST match the value passed to gog_auth_add_url, for the same reason `services` must: ' +
175
+ 'the two steps have to describe the same grant.',
176
+ ),
150
177
  },
151
- }, async ({ email, redirectUrl, services = defaultServices }) => {
178
+ }, async ({ email, redirectUrl, services = defaultServices, extraScopes }) => {
152
179
  try {
153
- return rawTextResult(await run(
154
- ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
155
- '--services', services, '--force-consent'],
156
- ));
180
+ const args = ['auth', 'add', email, '--remote', '--step', '2', '--auth-url', redirectUrl,
181
+ '--services', services, '--force-consent'];
182
+ if (extraScopes) args.push(`--extra-scopes=${extraScopes}`);
183
+ return rawTextResult(await run(args));
157
184
  } catch (err) {
158
185
  return errorResult(errorText(err));
159
186
  }
@@ -1,33 +1,54 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { z } from 'zod';
3
- import { accountParam, runOrDiagnose, registerRunTool } from './utils.js';
3
+ import { accountParam, runOrDiagnose, registerRunTool, pageTokenParam, pageAliasParam, resolvePageToken } from './utils.js';
4
+ import { annotateTruncatedList } from '../pagination.js';
4
5
 
5
6
  export function registerCalendarTools(server: McpServer): void {
6
7
  server.registerTool('gog_calendar_events', {
7
- description: 'List calendar events. Filters can be combined (e.g. --from + --to for a range, or --today for just today).',
8
+ description: 'List calendar events. Describe the window ONE way and one way only (gog >= 0.36.0 rejects the rest as ambiguous rather than silently discarding a flag): '
9
+ + 'today on its own; or from + to; or from + days; or days on its own (a window of that many days starting today). today cannot be combined with from, to or days, and days cannot be combined with to. '
10
+ + 'gog returns only 10 events by default, so a wide date range is USUALLY INCOMPLETE: raise max, or page with pageToken until the response carries no nextPageToken. '
11
+ + 'A response carrying "truncated": true is an incomplete view — never conclude an event does not exist from one.',
8
12
  annotations: { readOnlyHint: true },
9
13
  inputSchema: {
10
14
  calendarId: z.string().optional().describe('Calendar ID (default: primary calendar)'),
11
15
  from: z.string().optional().describe('Start time filter (RFC3339, date, or natural language)'),
12
- to: z.string().optional().describe('End time filter (RFC3339, date, or natural language)'),
13
- today: z.boolean().optional().describe('Only show today\'s events'),
16
+ to: z.string().optional().describe('End time filter (RFC3339, date, or natural language). Mutually exclusive with today and with days.'),
17
+ // gog >= 0.36.0 (openclaw/gogcli#981). Before that release --days sat in
18
+ // a switch arm evaluated ahead of --from, so `--from 2026-09-25 --days 5`
19
+ // silently threw --from away and answered for today instead — at exit 0,
20
+ // in a well-formed table. It is only exposed here now that it means what
21
+ // it says.
22
+ days: z.number().int().positive().optional().describe('Window LENGTH in days (calendar days, DST-aware), measured from `from` when one is given and from today otherwise. Use from + days for "the week of the 25th"; days alone for "the next N days". Mutually exclusive with to and with today.'),
23
+ today: z.boolean().optional().describe('Only show today\'s events. A complete window on its own — mutually exclusive with from, to and days.'),
14
24
  query: z.string().optional().describe('Free text search within events'),
15
- all: z.boolean().optional().describe('Fetch events from all calendars'),
25
+ max: z.number().int().optional().describe('Max events to return. gog defaults to 10, which silently hides the rest — raise it, or page with pageToken.'),
26
+ pageToken: pageTokenParam,
27
+ page: pageAliasParam,
28
+ all: z.boolean().optional().describe('Fetch events from ALL CALENDARS. NOTE: unlike the gmail search tools, this does NOT mean "all pages" — it widens the calendar set, not the page window. Use pageToken to reach later pages.'),
16
29
  eventTypes: z.array(z.enum(['default', 'birthday', 'focus-time', 'from-gmail', 'out-of-office', 'working-location'])).optional().describe('Filter to specific event types (repeatable)'),
17
30
  timezone: z.string().optional().describe('Display timezone for event times (IANA name, e.g. America/New_York, or "local" for the system timezone). Default: each event\'s timezone, then its calendar\'s timezone.'),
18
31
  account: accountParam,
19
32
  },
20
- }, async ({ calendarId, from, to, today, query, all, eventTypes, timezone, account }) => {
33
+ }, async ({ calendarId, from, to, days, today, query, max, pageToken, page, all, eventTypes, timezone, account }) => {
21
34
  const args = ['calendar', 'events'];
22
35
  if (calendarId) args.push(calendarId);
23
36
  if (from) args.push(`--from=${from}`);
24
37
  if (to) args.push(`--to=${to}`);
38
+ if (days !== undefined) args.push(`--days=${days}`);
25
39
  if (today) args.push('--today');
26
40
  if (query) args.push(`--query=${query}`);
41
+ if (max !== undefined) args.push(`--max=${max}`);
42
+ const token = resolvePageToken({ pageToken, page });
43
+ if (token) args.push(`--page=${token}`);
27
44
  if (all) args.push('--all');
28
45
  if (eventTypes) for (const t of eventTypes) args.push(`--event-types=${t}`);
29
46
  if (timezone) args.push(`--timezone=${timezone}`);
30
- return runOrDiagnose(args, { account });
47
+ const result = await runOrDiagnose(args, { account });
48
+ // No count probe here: unlike Gmail's list endpoints, the Calendar API has
49
+ // no cheap way to count a range exactly, so the warning carries the fact of
50
+ // truncation without inventing a total.
51
+ return annotateTruncatedList(result, 'events');
31
52
  });
32
53
 
33
54
  server.registerTool('gog_calendar_get', {